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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,4 +4,6 @@ export interface IMenuItem {
|
||||
action: () => void;
|
||||
badge?: string | number;
|
||||
badgeVariant?: 'default' | 'success' | 'warning' | 'error';
|
||||
closeable?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user