From 5cadd1fc7f74ab3a191c6a35ce6ba2736578b65d Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 10 Mar 2026 12:39:21 +0000 Subject: [PATCH] feat(appui-tabs): add support for left/right tab action buttons and content tab action APIs --- changelog.md | 9 ++ ts_web/00_commitinfo_data.ts | 2 +- .../dees-appui-maincontent.ts | 8 + .../dees-appui-tabs/dees-appui-tabs.demo.ts | 60 ++++--- .../dees-appui-tabs/dees-appui-tabs.ts | 150 ++++++++++++++++-- .../00group-appui/dees-appui/dees-appui.ts | 22 +++ ts_web/elements/interfaces/appconfig.ts | 4 +- ts_web/elements/interfaces/tab.ts | 8 + 8 files changed, 222 insertions(+), 41 deletions(-) diff --git a/changelog.md b/changelog.md index 54d6f5c..e7961c5 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-03-10 - 3.44.0 - feat(appui-tabs) +add support for left/right tab action buttons and content tab action APIs + +- Introduce ITabAction interface and add actionsLeft/actionsRight properties to dees-appui-tabs, dees-appui-maincontent, and dees-appui. +- Render action buttons with new styles and renderActions() helper, including disabled state and click handlers; wire actions into tab components. +- Add public clear() on dees-appui-tabs and improve tab selection logic to reset selection when tabs become empty or when the selected tab is removed. +- Expose setContentTabActionsLeft and setContentTabActionsRight on the DeesAppui programmatic API and update interfaces/appconfig accordingly. +- Update demos to showcase action buttons, add clear-all behavior, and adjust layout/styling for action areas. + ## 2026-03-09 - 3.43.4 - fix(media) remove deprecated dees-pdf and dees-pdf-preview components and bump several dependencies diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 6a2ac9f..83d4dfc 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.43.4', + version: '3.44.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-maincontent/dees-appui-maincontent.ts b/ts_web/elements/00group-appui/dees-appui-maincontent/dees-appui-maincontent.ts index f3c3fad..9c83ce6 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 @@ -53,6 +53,12 @@ export class DeesAppuiMaincontent extends DeesElement { @property({ type: Number }) accessor tabsAutoHideThreshold: number = 0; + @property({ type: Array }) + accessor tabActionsLeft: interfaces.ITabAction[] = []; + + @property({ type: Array }) + accessor tabActionsRight: interfaces.ITabAction[] = []; + public static styles = [ themeDefaultStyles, cssManager.defaultStyles, @@ -106,6 +112,8 @@ export class DeesAppuiMaincontent extends DeesElement { .tabStyle=${'horizontal'} .autoHide=${this.tabsAutoHide} .autoHideThreshold=${this.tabsAutoHideThreshold} + .actionsLeft=${this.tabActionsLeft} + .actionsRight=${this.tabActionsRight} @tab-select=${(e: CustomEvent) => this.handleTabSelect(e)} @tab-close=${(e: CustomEvent) => this.handleTabClose(e)} > 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 64f34ae..8fff34b 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 @@ -2,7 +2,7 @@ import { html, cssManager, css, DeesElement, customElement, state } from '@desig import * as interfaces from '../../interfaces/index.js'; import type { DeesAppuiTabs } from './dees-appui-tabs.js'; -// Interactive demo component for closeable tabs +// Interactive demo component for closeable tabs with action buttons @customElement('demo-closeable-tabs') class DemoCloseableTabs extends DeesElement { @state() @@ -18,24 +18,6 @@ class DemoCloseableTabs extends DeesElement { :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; @@ -66,17 +48,27 @@ class DemoCloseableTabs extends DeesElement { this.tabs = this.tabs.filter(t => t.key !== tabKey); } + private clearAll() { + const tabsEl = this.shadowRoot!.querySelector('dees-appui-tabs') as DeesAppuiTabs; + tabsEl?.clear(); + this.tabs = []; + this.tabCounter = 0; + } + render() { + const rightActions: interfaces.ITabAction[] = [ + { id: 'add', iconName: 'lucide:plus', action: () => this.addTab(), tooltip: 'New Tab' }, + { id: 'clear', iconName: 'lucide:trash2', action: () => this.clearAll(), tooltip: 'Clear All Tabs' }, + ]; + return html` this.removeTab(e.detail.tab.key)} > -
- -
- Click the X button on tabs to close them. The "Main" tab is not closeable. + Click the X button on tabs to close them. Use the + button to add tabs and the trash button to clear all.
Current tabs: ${this.tabs.length}
`; @@ -232,6 +224,16 @@ export const demoFunc = () => { { key: 'Archived', action: () => console.log('Archived clicked') }, ]; + const actionsLeft: interfaces.ITabAction[] = [ + { id: 'back', iconName: 'lucide:arrowLeft', action: () => console.log('Back'), tooltip: 'Go Back' }, + ]; + + const actionsRight: interfaces.ITabAction[] = [ + { id: 'add', iconName: 'lucide:plus', action: () => console.log('Add tab'), tooltip: 'New Tab' }, + { id: 'search', iconName: 'lucide:search', action: () => console.log('Search'), tooltip: 'Search Tabs' }, + { id: 'disabled', iconName: 'lucide:lock', action: () => {}, tooltip: 'Disabled Action', disabled: true }, + ]; + const demoContent = (text: string) => html`
${text} @@ -279,7 +281,17 @@ export const demoFunc = () => {
-
Closeable Tabs (Browser-style)
+
Tabs with Action Buttons
+ + ${demoContent('Action buttons can be placed on either side of the tab bar. They remain fixed while tabs scroll. The lock icon shows a disabled action.')} +
+ +
+
Closeable Tabs with Actions
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 37d2de7..ad2db7b 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 @@ -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`
-
-
- ${this.tabs.map(tab => this.renderTab(tab, isHorizontal))} + ${hasLeftActions ? this.renderActions(this.actionsLeft, 'left') : ''} +
+
+
+ ${this.tabs.map(tab => this.renderTab(tab, isHorizontal))} +
+
-
+ ${hasRightActions ? this.renderActions(this.actionsRight, 'right') : ''} ${this.showTabIndicator ? html`
` : ''}
`; @@ -353,6 +438,22 @@ export class DeesAppuiTabs extends DeesElement { `; } + private renderActions(actions: interfaces.ITabAction[], position: 'left' | 'right'): TemplateResult { + return html` +
+ ${actions.map(action => html` +
!action.disabled && action.action()} + > + +
+ `)} +
+ `; + } + 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) { 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')) { diff --git a/ts_web/elements/00group-appui/dees-appui/dees-appui.ts b/ts_web/elements/00group-appui/dees-appui/dees-appui.ts index 84a64cb..ae4740e 100644 --- a/ts_web/elements/00group-appui/dees-appui/dees-appui.ts +++ b/ts_web/elements/00group-appui/dees-appui/dees-appui.ts @@ -143,6 +143,12 @@ export class DeesAppui extends DeesElement { @property({ type: Object }) accessor maincontentSelectedTab: interfaces.IMenuItem | undefined = undefined; + @property({ type: Array }) + accessor contentTabActionsLeft: interfaces.ITabAction[] = []; + + @property({ type: Array }) + accessor contentTabActionsRight: interfaces.ITabAction[] = []; + // References to child components @state() accessor appbar: DeesAppuiBar | undefined = undefined; @@ -306,6 +312,8 @@ export class DeesAppui extends DeesElement { .showTabs=${this.maincontentTabsVisible} .tabsAutoHide=${this.contentTabsAutoHide} .tabsAutoHideThreshold=${this.contentTabsAutoHideThreshold} + .tabActionsLeft=${this.contentTabActionsLeft} + .tabActionsRight=${this.contentTabActionsRight} @tab-select=${(e: CustomEvent) => this.handleContentTabSelect(e)} @tab-close=${(e: CustomEvent) => this.handleContentTabClose(e)} > @@ -699,6 +707,20 @@ export class DeesAppui extends DeesElement { return this.maincontentSelectedTab; } + /** + * Set content tab action buttons on the left side + */ + public setContentTabActionsLeft(actions: interfaces.ITabAction[]): void { + this.contentTabActionsLeft = [...actions]; + } + + /** + * Set content tab action buttons on the right side + */ + public setContentTabActionsRight(actions: interfaces.ITabAction[]): void { + this.contentTabActionsRight = [...actions]; + } + // ========================================== // PROGRAMMATIC API: ACTIVITY LOG // ========================================== diff --git a/ts_web/elements/interfaces/appconfig.ts b/ts_web/elements/interfaces/appconfig.ts index 94ee00e..b5d7078 100644 --- a/ts_web/elements/interfaces/appconfig.ts +++ b/ts_web/elements/interfaces/appconfig.ts @@ -1,6 +1,6 @@ import type { TemplateResult } from '@design.estate/dees-element'; import type { IAppBarMenuItem } from './appbarmenuitem.js'; -import type { IMenuItem } from './tab.js'; +import type { IMenuItem, ITabAction } from './tab.js'; import type { IMenuGroup } from './menugroup.js'; import type { ISecondaryMenuGroup, ISecondaryMenuItem } from './secondarymenu.js'; @@ -134,6 +134,8 @@ export type TDeesAppui = HTMLElement & { removeContentTab: (tabKey: string) => void; selectContentTab: (tabKey: string) => void; getSelectedContentTab: () => IMenuItem | undefined; + setContentTabActionsLeft: (actions: ITabAction[]) => void; + setContentTabActionsRight: (actions: ITabAction[]) => void; activityLog: IActivityLogAPI; setActivityLogVisible: (visible: boolean) => void; toggleActivityLog: () => void; diff --git a/ts_web/elements/interfaces/tab.ts b/ts_web/elements/interfaces/tab.ts index 717b84b..31a942d 100644 --- a/ts_web/elements/interfaces/tab.ts +++ b/ts_web/elements/interfaces/tab.ts @@ -7,3 +7,11 @@ export interface IMenuItem { closeable?: boolean; onClose?: () => void; } + +export interface ITabAction { + id: string; + iconName: string; + action: () => void | Promise; + tooltip?: string; + disabled?: boolean; +}