feat(dees-appui-tabs): improve horizontal tabs UX with scroll fades, hover scrollbar, and smooth scroll-to-selected

This commit is contained in:
2025-12-30 10:27:34 +00:00
parent 34ed47e535
commit af1df1b3d6
3 changed files with 176 additions and 5 deletions

View File

@@ -1,5 +1,15 @@
# Changelog # Changelog
## 2025-12-30 - 3.11.0 - feat(dees-appui-tabs)
improve horizontal tabs UX with scroll fades, hover scrollbar, and smooth scroll-to-selected
- Add reactive scroll state (canScrollLeft / canScrollRight) and updateScrollState to track horizontal overflow.
- Introduce scroll-fade gradient elements and CSS to indicate overflow on left/right edges.
- Show a thin, styled scrollbar on hover (webkit + Firefox styling) instead of hiding it completely.
- Auto-scroll selected tab into view using scrollTabIntoView and smooth scroll when selecting a tab.
- Set up a ResizeObserver to recompute scroll state on container size changes and clean it up on disconnect.
- Ensure lifecycle hooks call updateScrollState (firstUpdated/updated) so indicators stay in sync after render/fonts ready.
## 2025-12-29 - 3.10.0 - feat(appui-tabs) ## 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 tabs and auto-hide behavior for content tabs, plus API and events to control them

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@design.estate/dees-catalog', name: '@design.estate/dees-catalog',
version: '3.10.0', version: '3.11.0',
description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.' description: 'A comprehensive library that provides dynamic web components for building sophisticated and modern web applications using JavaScript and TypeScript.'
} }

View File

@@ -4,6 +4,7 @@ import {
DeesElement, DeesElement,
type TemplateResult, type TemplateResult,
property, property,
state,
customElement, customElement,
html, html,
css, css,
@@ -39,6 +40,15 @@ export class DeesAppuiTabs extends DeesElement {
@property({ type: Number }) @property({ type: Number })
accessor autoHideThreshold: number = 0; accessor autoHideThreshold: number = 0;
// Scroll state for fade indicators
@state()
private accessor canScrollLeft: boolean = false;
@state()
private accessor canScrollRight: boolean = false;
private resizeObserver: ResizeObserver | null = null;
public static styles = [ public static styles = [
themeDefaultStyles, themeDefaultStyles,
cssManager.defaultStyles, cssManager.defaultStyles,
@@ -48,21 +58,56 @@ export class DeesAppuiTabs extends DeesElement {
display: block; display: block;
position: relative; position: relative;
width: 100%; width: 100%;
min-width: 0;
overflow: hidden;
} }
.tabs-wrapper { .tabs-wrapper {
position: relative; position: relative;
min-width: 0;
} }
.tabs-wrapper.horizontal-wrapper { .tabs-wrapper.horizontal-wrapper {
height: 48px; height: 48px;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')}; border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')};
box-sizing: border-box; box-sizing: border-box;
overflow: hidden;
}
/* Scroll fade indicators */
.scroll-fade {
position: absolute;
top: 0;
bottom: 1px;
width: 48px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease;
z-index: 10;
}
.scroll-fade-left {
left: 0;
background: linear-gradient(to right,
${cssManager.bdTheme('#ffffff', '#161616')} 0%,
${cssManager.bdTheme('rgba(255,255,255,0)', 'rgba(22,22,22,0)')} 100%);
}
.scroll-fade-right {
right: 0;
background: linear-gradient(to left,
${cssManager.bdTheme('#ffffff', '#161616')} 0%,
${cssManager.bdTheme('rgba(255,255,255,0)', 'rgba(22,22,22,0)')} 100%);
}
.scroll-fade.visible {
opacity: 1;
} }
.tabsContainer { .tabsContainer {
position: relative; position: relative;
user-select: none; user-select: none;
min-width: 0;
} }
.tabsContainer.horizontal { .tabsContainer.horizontal {
@@ -70,14 +115,39 @@ export class DeesAppuiTabs extends DeesElement {
align-items: center; align-items: center;
font-size: 14px; font-size: 14px;
overflow-x: auto; overflow-x: auto;
scrollbar-width: none; overflow-y: hidden;
scrollbar-width: thin;
scrollbar-color: transparent transparent;
height: 100%; height: 100%;
padding: 0 16px; padding: 0 16px;
gap: 4px; gap: 4px;
} }
/* Show scrollbar on hover */
.tabs-wrapper:hover .tabsContainer.horizontal {
scrollbar-color: ${cssManager.bdTheme('rgba(0,0,0,0.2)', 'rgba(255,255,255,0.2)')} transparent;
}
.tabsContainer.horizontal::-webkit-scrollbar { .tabsContainer.horizontal::-webkit-scrollbar {
display: none; height: 4px;
}
.tabsContainer.horizontal::-webkit-scrollbar-track {
background: transparent;
}
.tabsContainer.horizontal::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 2px;
transition: background 0.2s ease;
}
.tabs-wrapper: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 {
background: ${cssManager.bdTheme('rgba(0,0,0,0.35)', 'rgba(255,255,255,0.35)')};
} }
.tabsContainer.vertical { .tabsContainer.vertical {
@@ -258,6 +328,19 @@ export class DeesAppuiTabs extends DeesElement {
const wrapperClass = isHorizontal ? 'tabs-wrapper horizontal-wrapper' : 'vertical-wrapper'; const wrapperClass = isHorizontal ? 'tabs-wrapper horizontal-wrapper' : 'vertical-wrapper';
const containerClass = `tabsContainer ${this.tabStyle}`; const containerClass = `tabsContainer ${this.tabStyle}`;
if (isHorizontal) {
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))}
</div>
<div class="scroll-fade scroll-fade-right ${this.canScrollRight ? 'visible' : ''}"></div>
${this.showTabIndicator ? html`<div class="tabIndicator"></div>` : ''}
</div>
`;
}
return html` return html`
<div class="${wrapperClass}"> <div class="${wrapperClass}">
<div class="${containerClass}"> <div class="${containerClass}">
@@ -308,6 +391,11 @@ export class DeesAppuiTabs extends DeesElement {
this.selectedTab = tabArg; this.selectedTab = tabArg;
tabArg.action(); tabArg.action();
// Scroll selected tab into view
requestAnimationFrame(() => {
this.scrollTabIntoView(tabArg);
});
// Emit tab-select event // Emit tab-select event
this.dispatchEvent(new CustomEvent('tab-select', { this.dispatchEvent(new CustomEvent('tab-select', {
detail: { tab: tabArg }, detail: { tab: tabArg },
@@ -336,15 +424,87 @@ export class DeesAppuiTabs extends DeesElement {
if (this.tabs && this.tabs.length > 0) { if (this.tabs && this.tabs.length > 0) {
this.selectTab(this.tabs[0]); this.selectTab(this.tabs[0]);
} }
// Set up ResizeObserver for scroll state updates
this.setupResizeObserver();
// Initial scroll state check
requestAnimationFrame(() => {
this.updateScrollState();
});
}
async disconnectedCallback() {
await super.disconnectedCallback();
if (this.resizeObserver) {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
}
private setupResizeObserver() {
if (this.tabStyle !== 'horizontal') return;
this.resizeObserver = new ResizeObserver(() => {
this.updateScrollState();
});
const container = this.shadowRoot?.querySelector('.tabsContainer.horizontal');
if (container) {
this.resizeObserver.observe(container);
}
}
private handleScroll = () => {
this.updateScrollState();
};
private updateScrollState() {
const container = this.shadowRoot?.querySelector('.tabsContainer.horizontal') as HTMLElement;
if (!container) return;
const scrollLeft = container.scrollLeft;
const scrollWidth = container.scrollWidth;
const clientWidth = container.clientWidth;
// Small threshold to account for rounding
const threshold = 2;
this.canScrollLeft = scrollLeft > threshold;
this.canScrollRight = scrollLeft < scrollWidth - clientWidth - threshold;
}
private scrollTabIntoView(tab: interfaces.IMenuItem) {
if (this.tabStyle !== 'horizontal') return;
const tabIndex = this.tabs.indexOf(tab);
if (tabIndex === -1) return;
const container = this.shadowRoot?.querySelector('.tabsContainer.horizontal') as HTMLElement;
const tabElement = container?.querySelector(`.tab:nth-child(${tabIndex + 1})`) as HTMLElement;
if (tabElement && container) {
const containerRect = container.getBoundingClientRect();
const tabRect = tabElement.getBoundingClientRect();
// Check if tab is fully visible
const isFullyVisible =
tabRect.left >= containerRect.left &&
tabRect.right <= containerRect.right;
if (!isFullyVisible) {
tabElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
}
}
} }
async updated(changedProperties: Map<string, any>) { async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties); super.updated(changedProperties);
if (changedProperties.has('tabs') && this.tabs && this.tabs.length > 0 && !this.selectedTab) { if (changedProperties.has('tabs') && this.tabs && this.tabs.length > 0 && !this.selectedTab) {
this.selectTab(this.tabs[0]); this.selectTab(this.tabs[0]);
} }
if (changedProperties.has('selectedTab') || changedProperties.has('tabs')) { if (changedProperties.has('selectedTab') || changedProperties.has('tabs')) {
await this.updateComplete; await this.updateComplete;
// Wait for fonts to load on first update // Wait for fonts to load on first update
@@ -353,6 +513,7 @@ export class DeesAppuiTabs extends DeesElement {
} }
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.updateTabIndicator(); this.updateTabIndicator();
this.updateScrollState();
}); });
} }
} }