From 5f67bcfb710cf362a5a62fed1da7be03b68af84a Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 29 Dec 2025 23:33:38 +0000 Subject: [PATCH] feat(appui-tabs): add closeable tabs and auto-hide behavior for content tabs, plus API and events to control them --- changelog.md | 9 + ts_web/00_commitinfo_data.ts | 2 +- .../dees-appui-base/dees-appui-base.demo.ts | 20 ++ .../dees-appui-base/dees-appui-base.ts | 27 +++ .../dees-appui-maincontent.ts | 20 +- .../dees-appui-tabs/dees-appui-tabs.demo.ts | 219 +++++++++++++++++- .../dees-appui-tabs/dees-appui-tabs.ts | 74 +++++- ts_web/elements/interfaces/appconfig.ts | 1 + ts_web/elements/interfaces/tab.ts | 2 + 9 files changed, 369 insertions(+), 5 deletions(-) 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 { + + + `; } + + 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)} + > +
+ +
+
+ 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`` + } +
+
+ + + + + + +
+
+ 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)
+ +
+ +
+
Auto-hide Tabs
+ +
+
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` + + + + ` : ''; + 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; }