feat(appui-tabs): add closeable tabs and auto-hide behavior for content tabs, plus API and events to control them

This commit is contained in:
2025-12-29 23:33:38 +00:00
parent 7b39c871f3
commit 5f67bcfb71
9 changed files with 369 additions and 5 deletions

View File

@@ -162,10 +162,30 @@ class DemoDashboardView extends DeesElement {
<button class="ctx-btn" @click=${() => this.ctx?.appui.activityLog.add({ type: 'custom', user: 'Demo User', message: 'Button clicked from ctx!', iconName: 'lucide:mouse-pointer-click' })}>Add Activity Entry</button>
<button class="ctx-btn" @click=${() => this.ctx?.appui.setMainMenuBadge('tasks', 99)}>Set Tasks Badge to 99</button>
<button class="ctx-btn danger" @click=${() => this.ctx?.appui.clearMainMenuBadge('tasks')}>Clear Tasks Badge</button>
<button class="ctx-btn" @click=${() => this.ctx?.appui.setContentTabsAutoHide(true, 1)}>Auto-hide Tabs (≤1)</button>
<button class="ctx-btn danger" @click=${() => this.ctx?.appui.setContentTabsAutoHide(false)}>Disable Auto-hide</button>
<button class="ctx-btn success" @click=${() => this.addCloseableTab()}>Add Closeable Tab</button>
</div>
</div>
`;
}
private tabCounter = 0;
private addCloseableTab() {
if (!this.ctx) return;
this.tabCounter++;
const tabKey = `Tab ${this.tabCounter}`;
this.ctx.appui.addContentTab({
key: tabKey,
iconName: 'lucide:file',
action: () => console.log(`Selected ${tabKey}`),
closeable: true,
onClose: () => {
this.ctx?.appui.removeContentTab(tabKey);
}
});
}
}
// Settings view with route params and canDeactivate guard

View File

@@ -120,6 +120,12 @@ export class DeesAppuiBase extends DeesElement {
@property({ type: Boolean })
accessor maincontentTabsVisible: boolean = true;
@property({ type: Boolean })
accessor contentTabsAutoHide: boolean = false;
@property({ type: Number })
accessor contentTabsAutoHideThreshold: number = 0;
// Properties for maincontent
@property({ type: Array })
accessor maincontentTabs: interfaces.IMenuItem[] = [];
@@ -250,7 +256,10 @@ export class DeesAppuiBase extends DeesElement {
.tabs=${this.maincontentTabs}
.selectedTab=${this.maincontentSelectedTab}
.showTabs=${this.maincontentTabsVisible}
.tabsAutoHide=${this.contentTabsAutoHide}
.tabsAutoHideThreshold=${this.contentTabsAutoHideThreshold}
@tab-select=${(e: CustomEvent) => this.handleContentTabSelect(e)}
@tab-close=${(e: CustomEvent) => this.handleContentTabClose(e)}
>
<div class="view-container"></div>
<slot name="maincontent"></slot>
@@ -468,6 +477,16 @@ export class DeesAppuiBase extends DeesElement {
this.maincontentTabsVisible = visible;
}
/**
* Set content tabs auto-hide behavior
* @param enabled - Enable auto-hide feature
* @param threshold - Hide when tabs.length <= threshold (default 0 = hide when no tabs)
*/
public setContentTabsAutoHide(enabled: boolean, threshold: number = 0): void {
this.contentTabsAutoHide = enabled;
this.contentTabsAutoHideThreshold = threshold;
}
/**
* Set a badge on a main menu item
*/
@@ -1020,4 +1039,12 @@ export class DeesAppuiBase extends DeesElement {
composed: true
}));
}
private handleContentTabClose(e: CustomEvent) {
this.dispatchEvent(new CustomEvent('content-tab-close', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
}

View File

@@ -46,6 +46,12 @@ export class DeesAppuiMaincontent extends DeesElement {
@property({ type: Boolean })
accessor showTabs: boolean = true;
@property({ type: Boolean })
accessor tabsAutoHide: boolean = false;
@property({ type: Number })
accessor tabsAutoHideThreshold: number = 0;
public static styles = [
themeDefaultStyles,
cssManager.defaultStyles,
@@ -96,7 +102,10 @@ export class DeesAppuiMaincontent extends DeesElement {
.selectedTab=${this.selectedTab}
.showTabIndicator=${true}
.tabStyle=${'horizontal'}
.autoHide=${this.tabsAutoHide}
.autoHideThreshold=${this.tabsAutoHideThreshold}
@tab-select=${(e: CustomEvent) => this.handleTabSelect(e)}
@tab-close=${(e: CustomEvent) => this.handleTabClose(e)}
></dees-appui-tabs>
</div>
<div class="content-area">
@@ -109,7 +118,7 @@ export class DeesAppuiMaincontent extends DeesElement {
private handleTabSelect(e: CustomEvent) {
this.selectedTab = e.detail.tab;
// Re-emit the event
this.dispatchEvent(new CustomEvent('tab-select', {
detail: e.detail,
@@ -118,6 +127,15 @@ export class DeesAppuiMaincontent extends DeesElement {
}));
}
private handleTabClose(e: CustomEvent) {
// Re-emit the event
this.dispatchEvent(new CustomEvent('tab-close', {
detail: e.detail,
bubbles: true,
composed: true
}));
}
updated(changedProperties: Map<string | number | symbol, unknown>) {
super.updated(changedProperties);
if (changedProperties.has('showTabs')) {

View File

@@ -1,5 +1,212 @@
import { html, cssManager } from '@design.estate/dees-element';
import { html, cssManager, css, DeesElement, customElement, state } from '@design.estate/dees-element';
import * as interfaces from '../../interfaces/index.js';
import type { DeesAppuiTabs } from './dees-appui-tabs.js';
// Interactive demo component for closeable tabs
@customElement('demo-closeable-tabs')
class DemoCloseableTabs extends DeesElement {
@state()
accessor tabs: interfaces.IMenuItem[] = [
{ key: 'Main', iconName: 'lucide:home', action: () => console.log('Main clicked') },
];
@state()
accessor tabCounter: number = 0;
static styles = [
css`
:host {
display: block;
}
.controls {
display: flex;
gap: 8px;
margin-top: 16px;
}
button {
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
border: 1px solid ${cssManager.bdTheme('rgba(59, 130, 246, 0.3)', 'rgba(59, 130, 246, 0.3)')};
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.15s ease;
}
button:hover {
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.2)', 'rgba(59, 130, 246, 0.2)')};
}
.info {
margin-top: 16px;
padding: 12px 16px;
background: ${cssManager.bdTheme('rgba(0,0,0,0.02)', 'rgba(255,255,255,0.02)')};
border-radius: 6px;
font-size: 13px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
}
`
];
private addTab() {
this.tabCounter++;
const tabKey = `Document ${this.tabCounter}`;
this.tabs = [
...this.tabs,
{
key: tabKey,
iconName: 'lucide:file',
action: () => console.log(`${tabKey} clicked`),
closeable: true,
onClose: () => this.removeTab(tabKey)
}
];
}
private removeTab(tabKey: string) {
this.tabs = this.tabs.filter(t => t.key !== tabKey);
}
render() {
return html`
<dees-appui-tabs
.tabs=${this.tabs}
@tab-close=${(e: CustomEvent) => this.removeTab(e.detail.tab.key)}
></dees-appui-tabs>
<div class="controls">
<button @click=${() => this.addTab()}>+ Add New Tab</button>
</div>
<div class="info">
Click the X button on tabs to close them. The "Main" tab is not closeable.
<br>Current tabs: ${this.tabs.length}
</div>
`;
}
}
// Interactive demo for auto-hide feature
@customElement('demo-autohide-tabs')
class DemoAutoHideTabs extends DeesElement {
@state()
accessor tabs: interfaces.IMenuItem[] = [
{ key: 'Tab 1', iconName: 'lucide:file', action: () => console.log('Tab 1') },
{ key: 'Tab 2', iconName: 'lucide:file', action: () => console.log('Tab 2') },
];
@state()
accessor autoHide: boolean = true;
@state()
accessor threshold: number = 1;
static styles = [
css`
:host {
display: block;
}
.tabs-container {
min-height: 60px;
border: 1px dashed ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.tabs-container dees-appui-tabs {
width: 100%;
}
.placeholder {
color: ${cssManager.bdTheme('#a1a1aa', '#71717a')};
font-size: 13px;
font-style: italic;
}
.controls {
display: flex;
gap: 8px;
margin-top: 16px;
flex-wrap: wrap;
}
button {
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(59, 130, 246, 0.1)')};
border: 1px solid ${cssManager.bdTheme('rgba(59, 130, 246, 0.3)', 'rgba(59, 130, 246, 0.3)')};
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: all 0.15s ease;
}
button:hover {
background: ${cssManager.bdTheme('rgba(59, 130, 246, 0.2)', 'rgba(59, 130, 246, 0.2)')};
}
button.danger {
background: ${cssManager.bdTheme('rgba(239, 68, 68, 0.1)', 'rgba(239, 68, 68, 0.1)')};
border-color: ${cssManager.bdTheme('rgba(239, 68, 68, 0.3)', 'rgba(239, 68, 68, 0.3)')};
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
}
button.danger:hover {
background: ${cssManager.bdTheme('rgba(239, 68, 68, 0.2)', 'rgba(239, 68, 68, 0.2)')};
}
.info {
margin-top: 16px;
padding: 12px 16px;
background: ${cssManager.bdTheme('rgba(0,0,0,0.02)', 'rgba(255,255,255,0.02)')};
border-radius: 6px;
font-size: 13px;
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
}
`
];
private tabCounter = 2;
private addTab() {
this.tabCounter++;
this.tabs = [...this.tabs, {
key: `Tab ${this.tabCounter}`,
iconName: 'lucide:file',
action: () => console.log(`Tab ${this.tabCounter}`)
}];
}
private removeLastTab() {
if (this.tabs.length > 0) {
this.tabs = this.tabs.slice(0, -1);
}
}
private clearTabs() {
this.tabs = [];
}
render() {
const shouldHide = this.autoHide && this.tabs.length <= this.threshold;
return html`
<div class="tabs-container">
${shouldHide
? html`<span class="placeholder">Tabs hidden (${this.tabs.length} tabs ≤ threshold ${this.threshold})</span>`
: html`<dees-appui-tabs
.tabs=${this.tabs}
.autoHide=${this.autoHide}
.autoHideThreshold=${this.threshold}
></dees-appui-tabs>`
}
</div>
<div class="controls">
<button @click=${() => this.addTab()}>+ Add Tab</button>
<button class="danger" @click=${() => this.removeLastTab()}>- Remove Tab</button>
<button class="danger" @click=${() => this.clearTabs()}>Clear All</button>
<button @click=${() => { this.threshold = 0; }}>Threshold: 0</button>
<button @click=${() => { this.threshold = 1; }}>Threshold: 1</button>
<button @click=${() => { this.threshold = 2; }}>Threshold: 2</button>
</div>
<div class="info">
Auto-hide: ${this.autoHide ? 'ON' : 'OFF'} | Threshold: ${this.threshold} | Tabs: ${this.tabs.length}
<br>Tabs will hide when count ≤ threshold.
</div>
`;
}
}
export const demoFunc = () => {
const horizontalTabs: interfaces.IMenuItem[] = [
@@ -71,6 +278,16 @@ export const demoFunc = () => {
${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.')}
</div>
<div class="section">
<div class="section-title">Closeable Tabs (Browser-style)</div>
<demo-closeable-tabs></demo-closeable-tabs>
</div>
<div class="section">
<div class="section-title">Auto-hide Tabs</div>
<demo-autohide-tabs></demo-autohide-tabs>
</div>
<div class="section">
<div class="section-title">Vertical Tabs Layout</div>
<div class="two-column">

View File

@@ -33,6 +33,12 @@ export class DeesAppuiTabs extends DeesElement {
@property({ type: String })
accessor tabStyle: 'horizontal' | 'vertical' = 'horizontal';
@property({ type: Boolean })
accessor autoHide: boolean = false;
@property({ type: Number })
accessor autoHideThreshold: number = 0;
public static styles = [
themeDefaultStyles,
cssManager.defaultStyles,
@@ -198,10 +204,50 @@ export class DeesAppuiTabs extends DeesElement {
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()}
`;
@@ -225,15 +271,23 @@ export class DeesAppuiTabs extends DeesElement {
private renderTab(tab: interfaces.IMenuItem, isHorizontal: boolean): TemplateResult {
const isSelected = tab === this.selectedTab;
const classes = `tab ${isSelected ? 'selectedTab' : ''}`;
const closeButton = tab.closeable ? html`
<span class="tab-close" @click="${(e: Event) => this.closeTab(e, tab)}">
<dees-icon .icon=${'lucide:x'} style="font-size: 12px;"></dees-icon>
</span>
` : '';
const content = isHorizontal ? html`
<span class="tab-content">
${this.renderTabIcon(tab)}
${tab.key}
</span>
${closeButton}
` : html`
${this.renderTabIcon(tab)}
${tab.key}
${closeButton}
`;
return html`
@@ -253,7 +307,7 @@ export class DeesAppuiTabs extends DeesElement {
private selectTab(tabArg: interfaces.IMenuItem) {
this.selectedTab = tabArg;
tabArg.action();
// Emit tab-select event
this.dispatchEvent(new CustomEvent('tab-select', {
detail: { tab: tabArg },
@@ -262,6 +316,22 @@ export class DeesAppuiTabs extends DeesElement {
}));
}
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]);