feat(dees-appui-tabs): improve horizontal tabs UX with scroll fades, hover scrollbar, and smooth scroll-to-selected
This commit is contained in:
10
changelog.md
10
changelog.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +424,78 @@ 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>) {
|
||||||
@@ -353,6 +513,7 @@ export class DeesAppuiTabs extends DeesElement {
|
|||||||
}
|
}
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
this.updateTabIndicator();
|
this.updateTabIndicator();
|
||||||
|
this.updateScrollState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user