feat(appui-tabs): add support for left/right tab action buttons and content tab action APIs
This commit is contained in:
@@ -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)}
|
||||
></dees-appui-tabs>
|
||||
|
||||
@@ -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`
|
||||
<dees-appui-tabs
|
||||
.tabs=${this.tabs}
|
||||
.actionsRight=${rightActions}
|
||||
@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.
|
||||
Click the X button on tabs to close them. Use the + button to add tabs and the trash button to clear all.
|
||||
<br>Current tabs: ${this.tabs.length}
|
||||
</div>
|
||||
`;
|
||||
@@ -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`
|
||||
<div style="padding: 24px; color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};">
|
||||
${text}
|
||||
@@ -279,7 +281,17 @@ export const demoFunc = () => {
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Closeable Tabs (Browser-style)</div>
|
||||
<div class="section-title">Tabs with Action Buttons</div>
|
||||
<dees-appui-tabs
|
||||
.tabs=${horizontalTabs}
|
||||
.actionsLeft=${actionsLeft}
|
||||
.actionsRight=${actionsRight}
|
||||
></dees-appui-tabs>
|
||||
${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.')}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">Closeable Tabs with Actions</div>
|
||||
<demo-closeable-tabs></demo-closeable-tabs>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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
|
||||
// ==========================================
|
||||
|
||||
Reference in New Issue
Block a user