diff --git a/changelog.md b/changelog.md
index 0cf90b8..8bc80ad 100644
--- a/changelog.md
+++ b/changelog.md
@@ -1,5 +1,14 @@
# Changelog
+## 2025-12-29 - 3.10.0 - feat(appui-tabs)
+add closeable tabs and auto-hide behavior for content tabs, plus API and events to control them
+
+- Add closeable tab support: IMenuItem.closeable & IMenuItem.onClose; dees-appui-tabs renders a close button, invokes onClose, and emits a 'tab-close' event.
+- Add auto-hide feature: dees-appui-tabs (autoHide, autoHideThreshold) and corresponding properties in dees-appui-maincontent/dees-appui-base to hide tabs when count ≤ threshold.
+- Expose new API: dees-appui-base.setContentTabsAutoHide(enabled, threshold) and update appconfig interface to include setContentTabsAutoHide.
+- Re-emit 'tab-close' events from dees-appui-maincontent and dees-appui-base so parent components can react to tab closures.
+- Add interactive demos (demo-closeable-tabs, demo-autohide-tabs) demonstrating the new closeable and auto-hide behaviors and controls.
+
## 2025-12-29 - 3.9.0 - feat(dees-appui-mainmenu)
add status badges to main menu items with theme-aware styling
diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts
index 25baeaa..5e90f77 100644
--- a/ts_web/00_commitinfo_data.ts
+++ b/ts_web/00_commitinfo_data.ts
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@design.estate/dees-catalog',
- version: '3.9.0',
+ version: '3.10.0',
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
}
diff --git a/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.demo.ts b/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.demo.ts
index 900ded7..17af031 100644
--- a/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.demo.ts
+++ b/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.demo.ts
@@ -162,10 +162,30 @@ class DemoDashboardView extends DeesElement {
this.ctx?.appui.activityLog.add({ type: 'custom', user: 'Demo User', message: 'Button clicked from ctx!', iconName: 'lucide:mouse-pointer-click' })}>Add Activity Entry
this.ctx?.appui.setMainMenuBadge('tasks', 99)}>Set Tasks Badge to 99
this.ctx?.appui.clearMainMenuBadge('tasks')}>Clear Tasks Badge
+ this.ctx?.appui.setContentTabsAutoHide(true, 1)}>Auto-hide Tabs (≤1)
+ this.ctx?.appui.setContentTabsAutoHide(false)}>Disable Auto-hide
+ this.addCloseableTab()}>Add Closeable Tab
`;
}
+
+ 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
diff --git a/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.ts b/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.ts
index 78e8507..711544b 100644
--- a/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.ts
+++ b/ts_web/elements/00group-appui/dees-appui-base/dees-appui-base.ts
@@ -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)}
>
@@ -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
+ }));
+ }
}
diff --git a/ts_web/elements/00group-appui/dees-appui-maincontent/dees-appui-maincontent.ts b/ts_web/elements/00group-appui/dees-appui-maincontent/dees-appui-maincontent.ts
index 629d19d..2918b13 100644
--- a/ts_web/elements/00group-appui/dees-appui-maincontent/dees-appui-maincontent.ts
+++ b/ts_web/elements/00group-appui/dees-appui-maincontent/dees-appui-maincontent.ts
@@ -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)}
>
@@ -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
) {
super.updated(changedProperties);
if (changedProperties.has('showTabs')) {
diff --git a/ts_web/elements/00group-appui/dees-appui-tabs/dees-appui-tabs.demo.ts b/ts_web/elements/00group-appui/dees-appui-tabs/dees-appui-tabs.demo.ts
index ad638e4..64f34ae 100644
--- a/ts_web/elements/00group-appui/dees-appui-tabs/dees-appui-tabs.demo.ts
+++ b/ts_web/elements/00group-appui/dees-appui-tabs/dees-appui-tabs.demo.ts
@@ -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`
+ this.removeTab(e.detail.tab.key)}
+ >
+
+ this.addTab()}>+ Add New Tab
+
+
+ Click the X button on tabs to close them. The "Main" tab is not closeable.
+ Current tabs: ${this.tabs.length}
+
+ `;
+ }
+}
+
+// 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`
+
+ ${shouldHide
+ ? html`Tabs hidden (${this.tabs.length} tabs ≤ threshold ${this.threshold}) `
+ : html` `
+ }
+
+
+ this.addTab()}>+ Add Tab
+ this.removeLastTab()}>- Remove Tab
+ this.clearTabs()}>Clear All
+ { this.threshold = 0; }}>Threshold: 0
+ { this.threshold = 1; }}>Threshold: 1
+ { this.threshold = 2; }}>Threshold: 2
+
+
+ Auto-hide: ${this.autoHide ? 'ON' : 'OFF'} | Threshold: ${this.threshold} | Tabs: ${this.tabs.length}
+ Tabs will hide when count ≤ threshold.
+
+ `;
+ }
+}
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.')}
+
+
Closeable Tabs (Browser-style)
+
+
+
+
+
Vertical Tabs Layout
diff --git a/ts_web/elements/00group-appui/dees-appui-tabs/dees-appui-tabs.ts b/ts_web/elements/00group-appui/dees-appui-tabs/dees-appui-tabs.ts
index f93d056..0ef634d 100644
--- a/ts_web/elements/00group-appui/dees-appui-tabs/dees-appui-tabs.ts
+++ b/ts_web/elements/00group-appui/dees-appui-tabs/dees-appui-tabs.ts
@@ -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`
+ this.closeTab(e, tab)}">
+
+
+ ` : '';
+
const content = isHorizontal ? html`
${this.renderTabIcon(tab)}
${tab.key}
+ ${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]);
diff --git a/ts_web/elements/interfaces/appconfig.ts b/ts_web/elements/interfaces/appconfig.ts
index 05bea79..332f19a 100644
--- a/ts_web/elements/interfaces/appconfig.ts
+++ b/ts_web/elements/interfaces/appconfig.ts
@@ -22,6 +22,7 @@ export type TDeesAppuiBase = HTMLElement & {
setSecondaryMenuCollapsed: (collapsed: boolean) => void;
setSecondaryMenuVisible: (visible: boolean) => void;
setContentTabsVisible: (visible: boolean) => void;
+ setContentTabsAutoHide: (enabled: boolean, threshold?: number) => void;
setMainMenuBadge: (tabKey: string, badge: string | number) => void;
clearMainMenuBadge: (tabKey: string) => void;
setSecondaryMenu: (config: { heading?: string; groups: IMenuGroup[] }) => void;
diff --git a/ts_web/elements/interfaces/tab.ts b/ts_web/elements/interfaces/tab.ts
index fc1fcf6..717b84b 100644
--- a/ts_web/elements/interfaces/tab.ts
+++ b/ts_web/elements/interfaces/tab.ts
@@ -4,4 +4,6 @@ export interface IMenuItem {
action: () => void;
badge?: string | number;
badgeVariant?: 'default' | 'success' | 'warning' | 'error';
+ closeable?: boolean;
+ onClose?: () => void;
}