From af1df1b3d6c836915fff512c2db95c0a7aa2c881 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Tue, 30 Dec 2025 10:27:34 +0000 Subject: [PATCH] feat(dees-appui-tabs): improve horizontal tabs UX with scroll fades, hover scrollbar, and smooth scroll-to-selected --- changelog.md | 10 ++ ts_web/00_commitinfo_data.ts | 2 +- .../dees-appui-tabs/dees-appui-tabs.ts | 169 +++++++++++++++++- 3 files changed, 176 insertions(+), 5 deletions(-) diff --git a/changelog.md b/changelog.md index 8bc80ad..3fd6fe8 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # 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) add closeable tabs and auto-hide behavior for content tabs, plus API and events to control them diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 5e90f77..451e9b1 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.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.' } 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 0ef634d..3cdcef6 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 @@ -4,6 +4,7 @@ import { DeesElement, type TemplateResult, property, + state, customElement, html, css, @@ -39,6 +40,15 @@ export class DeesAppuiTabs extends DeesElement { @property({ type: Number }) 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 = [ themeDefaultStyles, cssManager.defaultStyles, @@ -48,21 +58,56 @@ export class DeesAppuiTabs extends DeesElement { display: block; position: relative; width: 100%; + min-width: 0; + overflow: hidden; } .tabs-wrapper { position: relative; + min-width: 0; } .tabs-wrapper.horizontal-wrapper { height: 48px; border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')}; 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 { position: relative; user-select: none; + min-width: 0; } .tabsContainer.horizontal { @@ -70,14 +115,39 @@ export class DeesAppuiTabs extends DeesElement { align-items: center; font-size: 14px; overflow-x: auto; - scrollbar-width: none; + overflow-y: hidden; + scrollbar-width: thin; + scrollbar-color: transparent transparent; height: 100%; padding: 0 16px; 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 { - 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 { @@ -258,6 +328,19 @@ export class DeesAppuiTabs extends DeesElement { const wrapperClass = isHorizontal ? 'tabs-wrapper horizontal-wrapper' : 'vertical-wrapper'; const containerClass = `tabsContainer ${this.tabStyle}`; + if (isHorizontal) { + return html` +
+
+
+ ${this.tabs.map(tab => this.renderTab(tab, isHorizontal))} +
+
+ ${this.showTabIndicator ? html`
` : ''} +
+ `; + } + return html`
@@ -308,6 +391,11 @@ export class DeesAppuiTabs extends DeesElement { this.selectedTab = tabArg; tabArg.action(); + // Scroll selected tab into view + requestAnimationFrame(() => { + this.scrollTabIntoView(tabArg); + }); + // Emit tab-select event this.dispatchEvent(new CustomEvent('tab-select', { detail: { tab: tabArg }, @@ -336,15 +424,87 @@ export class DeesAppuiTabs extends DeesElement { if (this.tabs && this.tabs.length > 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) { super.updated(changedProperties); - + if (changedProperties.has('tabs') && this.tabs && this.tabs.length > 0 && !this.selectedTab) { this.selectTab(this.tabs[0]); } - + if (changedProperties.has('selectedTab') || changedProperties.has('tabs')) { await this.updateComplete; // Wait for fonts to load on first update @@ -353,6 +513,7 @@ export class DeesAppuiTabs extends DeesElement { } requestAnimationFrame(() => { this.updateTabIndicator(); + this.updateScrollState(); }); } }