feat(appui-tabs): add support for left/right tab action buttons and content tab action APIs

This commit is contained in:
2026-03-10 12:39:21 +00:00
parent 1795235c6d
commit 5cadd1fc7f
8 changed files with 222 additions and 41 deletions

View File

@@ -41,6 +41,12 @@ export class DeesAppuiTabs extends DeesElement {
@property({ type: Number })
accessor autoHideThreshold: number = 0;
@property({ type: Array })
accessor actionsLeft: interfaces.ITabAction[] = [];
@property({ type: Array })
accessor actionsRight: interfaces.ITabAction[] = [];
// Scroll state for fade indicators
@state()
private accessor canScrollLeft: boolean = false;
@@ -73,6 +79,8 @@ export class DeesAppuiTabs extends DeesElement {
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
box-sizing: border-box;
overflow: hidden;
display: flex;
align-items: stretch;
}
/* Scroll fade indicators */
@@ -105,6 +113,72 @@ export class DeesAppuiTabs extends DeesElement {
opacity: 1;
}
.scroll-area {
position: relative;
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
}
/* Tab action buttons */
.tab-actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
padding: 0 4px;
}
.tab-actions.left {
padding-left: 12px;
padding-right: 8px;
border-right: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
}
.tab-actions.right {
padding-right: 12px;
padding-left: 8px;
border-left: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
}
.tab-action-button {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
background: transparent;
color: ${cssManager.bdTheme('#71717a', '#71717a')};
flex-shrink: 0;
}
.tab-action-button:hover {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.06)', 'rgba(255, 255, 255, 0.06)')};
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
}
.tab-action-button:active {
background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')};
}
.tab-action-button.disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tab-action-button.disabled:hover {
background: transparent;
color: ${cssManager.bdTheme('#71717a', '#71717a')};
}
.tab-action-button dees-icon {
font-size: 16px;
}
.tabsContainer {
position: relative;
user-select: none;
@@ -121,12 +195,14 @@ export class DeesAppuiTabs extends DeesElement {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
height: 100%;
width: 100%;
padding: 0 16px;
gap: 4px;
}
/* Show scrollbar on hover */
.tabs-wrapper:hover .tabsContainer.horizontal {
.tabs-wrapper:hover .tabsContainer.horizontal,
.scroll-area:hover .tabsContainer.horizontal {
scrollbar-color: ${cssManager.bdTheme('rgba(0,0,0,0.2)', 'rgba(255,255,255,0.2)')} transparent;
}
@@ -144,11 +220,13 @@ export class DeesAppuiTabs extends DeesElement {
transition: background 0.2s ease;
}
.tabs-wrapper:hover .tabsContainer.horizontal::-webkit-scrollbar-thumb {
.tabs-wrapper:hover .tabsContainer.horizontal::-webkit-scrollbar-thumb,
.scroll-area: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 {
.tabs-wrapper:hover .tabsContainer.horizontal::-webkit-scrollbar-thumb:hover,
.scroll-area:hover .tabsContainer.horizontal::-webkit-scrollbar-thumb:hover {
background: ${cssManager.bdTheme('rgba(0,0,0,0.35)', 'rgba(255,255,255,0.35)')};
}
@@ -331,13 +409,20 @@ export class DeesAppuiTabs extends DeesElement {
const containerClass = `tabsContainer ${this.tabStyle}`;
if (isHorizontal) {
const hasLeftActions = this.actionsLeft && this.actionsLeft.length > 0;
const hasRightActions = this.actionsRight && this.actionsRight.length > 0;
return html`
<div class="${wrapperClass}">
<div class="scroll-fade scroll-fade-left ${this.canScrollLeft ? 'visible' : ''}"></div>
<div class="${containerClass}" @scroll=${this.handleScroll}>
${this.tabs.map(tab => this.renderTab(tab, isHorizontal))}
${hasLeftActions ? this.renderActions(this.actionsLeft, 'left') : ''}
<div class="scroll-area">
<div class="scroll-fade scroll-fade-left ${this.canScrollLeft ? 'visible' : ''}"></div>
<div class="${containerClass}" @scroll=${this.handleScroll}>
${this.tabs.map(tab => this.renderTab(tab, isHorizontal))}
</div>
<div class="scroll-fade scroll-fade-right ${this.canScrollRight ? 'visible' : ''}"></div>
</div>
<div class="scroll-fade scroll-fade-right ${this.canScrollRight ? 'visible' : ''}"></div>
${hasRightActions ? this.renderActions(this.actionsRight, 'right') : ''}
${this.showTabIndicator ? html`<div class="tabIndicator"></div>` : ''}
</div>
`;
@@ -353,6 +438,22 @@ export class DeesAppuiTabs extends DeesElement {
`;
}
private renderActions(actions: interfaces.ITabAction[], position: 'left' | 'right'): TemplateResult {
return html`
<div class="tab-actions ${position}">
${actions.map(action => html`
<div
class="tab-action-button ${action.disabled ? 'disabled' : ''}"
title="${action.tooltip || action.id}"
@click=${() => !action.disabled && action.action()}
>
<dees-icon .icon=${action.iconName}></dees-icon>
</div>
`)}
</div>
`;
}
private renderTab(tab: interfaces.IMenuItem, isHorizontal: boolean): TemplateResult {
const isSelected = tab === this.selectedTab;
const classes = `tab ${isSelected ? 'selectedTab' : ''}`;
@@ -406,6 +507,14 @@ export class DeesAppuiTabs extends DeesElement {
}));
}
/**
* Clear all tabs and reset selection.
*/
public clear(): void {
this.tabs = [];
this.selectedTab = null;
}
private closeTab(e: Event, tab: interfaces.IMenuItem) {
e.stopPropagation(); // Don't select tab when closing
@@ -423,14 +532,9 @@ export class DeesAppuiTabs extends DeesElement {
}
firstUpdated() {
if (this.tabs && this.tabs.length > 0) {
this.selectTab(this.tabs[0]);
}
// Set up ResizeObserver for scroll state updates
// Tab selection is handled by updated() lifecycle
this.setupResizeObserver();
// Initial scroll state check
requestAnimationFrame(() => {
this.updateScrollState();
});
@@ -503,8 +607,24 @@ export class DeesAppuiTabs extends DeesElement {
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('tabs')) {
if (!this.tabs || this.tabs.length === 0) {
// Tabs are empty => reset selection
if (this.selectedTab !== null) {
this.selectedTab = null;
this.dispatchEvent(new CustomEvent('tab-select', {
detail: { tab: null },
bubbles: true,
composed: true,
}));
}
} else if (this.selectedTab && !this.tabs.includes(this.selectedTab)) {
// Selected tab was removed => select first available
this.selectTab(this.tabs[0]);
} else if (!this.selectedTab) {
// Tabs exist but nothing selected => select first
this.selectTab(this.tabs[0]);
}
}
if (changedProperties.has('selectedTab') || changedProperties.has('tabs')) {