diff --git a/packages/uui-tabs/lib/uui-tab-group.element.ts b/packages/uui-tabs/lib/uui-tab-group.element.ts index 96458b05b..fcd644d2a 100644 --- a/packages/uui-tabs/lib/uui-tab-group.element.ts +++ b/packages/uui-tabs/lib/uui-tab-group.element.ts @@ -1,10 +1,6 @@ import { defineElement } from '@umbraco-ui/uui-base/lib/registration'; -import { demandCustomElement } from '@umbraco-ui/uui-base/lib/utils'; -import type { UUIButtonElement } from '@umbraco-ui/uui-button/lib'; -import type { UUIPopoverContainerElement } from '@umbraco-ui/uui-popover-container/lib'; import { css, html, LitElement } from 'lit'; -import { property, query, queryAssignedElements } from 'lit/decorators.js'; -import { repeat } from 'lit/directives/repeat.js'; +import { query, queryAssignedElements } from 'lit/decorators.js'; import type { UUITabElement } from './uui-tab.element'; @@ -19,13 +15,10 @@ import type { UUITabElement } from './uui-tab.element'; */ @defineElement('uui-tab-group') export class UUITabGroupElement extends LitElement { - @query('#more-button') - private _moreButtonElement!: UUIButtonElement; - - @query('#popover-container') - private _popoverContainerElement!: UUIPopoverContainerElement; - @query('#main') private _mainElement!: HTMLElement; + @query('#grid') private _gridElement!: HTMLElement; + @query('#more-button-left') private _moreButtonLeftElement!: HTMLElement; + @query('#more-button-right') private _moreButtonRightElement!: HTMLElement; @queryAssignedElements({ flatten: true, @@ -33,33 +26,9 @@ export class UUITabGroupElement extends LitElement { }) private _slottedNodes?: HTMLElement[]; - /** Stores the current gap used in the breakpoints */ - #currentGap = 0; - - /** - * Set the flex direction of the content of the dropdown. - * @type {string} - * @attr - * @default vertical - */ - @property({ - type: String, - reflect: true, - attribute: 'dropdown-content-direction', - }) - dropdownContentDirection: 'vertical' | 'horizontal' = 'vertical'; - - #tabElements: HTMLElement[] = []; - - #hiddenTabElements: UUITabElement[] = []; - #hiddenTabElementsMap: Map = new Map(); - - #visibilityBreakpoints: number[] = []; + #tabElements: UUITabElement[] = []; #resizeObserver = new ResizeObserver(this.#onResize.bind(this)); - #tabResizeObservers: ResizeObserver[] = []; - - #breakPointCalculationInProgress = false; connectedCallback() { super.connectedCallback(); @@ -69,40 +38,31 @@ export class UUITabGroupElement extends LitElement { disconnectedCallback() { super.disconnectedCallback(); this.#resizeObserver.unobserve(this); + this._gridElement.removeEventListener( + 'scroll', + this.#updateScrollButtonsAfterScroll.bind(this), + ); + this.#cleanupTabs(); } async #initialize() { - demandCustomElement(this, 'uui-button'); - demandCustomElement(this, 'uui-popover-container'); - demandCustomElement(this, 'uui-symbol-more'); - - if (!this.hasAttribute('role')) this.setAttribute('role', 'tablist'); - await this.updateComplete; this.#resizeObserver.observe(this._mainElement); + this._gridElement.addEventListener( + 'scroll', + this.#updateScrollButtonsAfterScroll.bind(this), + ); } - #onResize(entries: ResizeObserverEntry[]) { - // Check if the gap css custom property has changed. - const gapCSSVar = Number.parseFloat( - this.style.getPropertyValue('--uui-tab-group-gap'), - ); - const newGap = Number.isNaN(gapCSSVar) ? 0 : gapCSSVar; - if (newGap !== this.#currentGap) { - this.#calculateBreakPoints(); - } else { - this.#updateCollapsibleTabs(entries[0].contentBoxSize[0].inlineSize); - } + #onResize() { + this.#updateScrollButtons(); } #cleanupTabs() { this.#tabElements.forEach(el => { el.removeEventListener('click', this.#onTabClicked); - this.#tabResizeObservers.forEach(observer => observer.disconnect()); }); - this.#tabResizeObservers.length = 0; - this.#visibilityBreakpoints.length = 0; } #onSlotChange() { @@ -112,182 +72,103 @@ export class UUITabGroupElement extends LitElement { this.#tabElements.forEach(el => { el.addEventListener('click', this.#onTabClicked); - const observer = new ResizeObserver( - this.#calculateBreakPoints.bind(this), - ); - observer.observe(el); - this.#tabResizeObservers.push(observer); }); + + this.#updateScrollButtons(); } #onTabClicked = (e: MouseEvent) => { const selectedElement = e.currentTarget as HTMLElement; if (this.#isElementTabLike(selectedElement)) { selectedElement.active = true; - const linkedElement = this.#hiddenTabElementsMap.get(selectedElement); - - if (linkedElement) { - linkedElement.active = true; - } // Reset all other tabs - const filtered = [ - ...this.#tabElements, - ...this.#hiddenTabElements, - ].filter(el => el !== selectedElement && el !== linkedElement); - - filtered.forEach(el => { - if (this.#isElementTabLike(el)) { - el.active = false; - } - }); - - // Check if there are any active tabs in the dropdown - const hasActiveHidden = this.#hiddenTabElements.some( - el => el.active && el !== linkedElement, - ); - - hasActiveHidden - ? this._moreButtonElement.classList.add('active-inside') - : this._moreButtonElement.classList.remove('active-inside'); + this.#tabElements + .filter(el => el !== selectedElement) + .forEach(el => (el.active = false)); } }; - async #calculateBreakPoints() { - if (this.#breakPointCalculationInProgress) return; - - // Prevent multiple calculations from happening in the same frame - this.#breakPointCalculationInProgress = true; - requestAnimationFrame(() => { - this.#breakPointCalculationInProgress = false; - }); - - // Whenever a tab is added or removed, we need to recalculate the breakpoints - - await this.updateComplete; // Wait for the tabs to be rendered - - const gapCSSVar = Number.parseFloat( - this.style.getPropertyValue('--uui-tab-group-gap'), - ); - const gap = Number.isNaN(gapCSSVar) ? 0 : gapCSSVar; - this.#currentGap = gap; - let childrenWidth = 0; - - for (let i = 0; i < this.#tabElements.length; i++) { - this.#tabElements[i].style.display = ''; - childrenWidth += this.#tabElements[i].offsetWidth; - this.#visibilityBreakpoints[i] = childrenWidth; - // Add the gap, which will then be included in the next breakpoint: - childrenWidth += gap; + #setTabArray() { + if (this._slottedNodes) { + this.#tabElements = Array.from(this._slottedNodes).filter( + this.#isElementTabLike, + null, + ) as UUITabElement[]; + } else { + this.#tabElements = []; } - - const tolerance = 2; - this._mainElement.style.width = childrenWidth - gap + tolerance + 'px'; - - this.#updateCollapsibleTabs(this._mainElement.offsetWidth); } - #setTabArray() { - this.#tabElements = this._slottedNodes ? this._slottedNodes : []; - this.#calculateBreakPoints(); + #isElementTabLike(el: any): el is UUITabElement { + return ( + typeof el === 'object' && 'active' in el && typeof el.active === 'boolean' + ); + } + #scrollLeft() { + this._gridElement.scrollBy({ left: -100, behavior: 'smooth' }); } - #updateCollapsibleTabs(containerWidth: number) { - const moreButtonWidth = this._moreButtonElement.offsetWidth; + #scrollRight() { + this._gridElement.scrollBy({ left: 100, behavior: 'smooth' }); + } - const containerWithoutButtonWidth = - containerWidth - (moreButtonWidth ? moreButtonWidth : 0); + #scrollTimeout: number | undefined; - // Do the update - // Reset the hidden tabs - this.#hiddenTabElements.forEach(el => { - el.removeEventListener('click', this.#onTabClicked); - }); - this.#hiddenTabElements = []; - this.#hiddenTabElementsMap.clear(); - - let hasActiveTabInDropdown = false; - - const len = this.#visibilityBreakpoints.length; - for (let i = 0; i < len; i++) { - const breakpoint = this.#visibilityBreakpoints[i]; - const tab = this.#tabElements[i] as UUITabElement; - - // If breakpoint is smaller than the container width, then show the tab. - // If last breakpoint, then we will use the containerWidth, as we do not want to include the more-button in that calculation. - if ( - breakpoint <= - (i === len - 1 ? containerWidth : containerWithoutButtonWidth) - ) { - // Show this tab: - tab.style.display = ''; - } else { - // Make a proxy tab to put in the hidden tabs container and link it to the original tab - const proxyTab = tab.cloneNode(true) as UUITabElement; - proxyTab.addEventListener('click', this.#onTabClicked); - proxyTab.classList.add('hidden-tab'); - proxyTab.style.display = ''; - proxyTab.orientation = this.dropdownContentDirection; - - // Link the proxy tab to the original tab - this.#hiddenTabElementsMap.set(proxyTab, tab); - this.#hiddenTabElementsMap.set(tab, proxyTab); - - this.#hiddenTabElements.push(proxyTab); - - tab.style.display = 'none'; - if (tab.active) { - hasActiveTabInDropdown = true; - } - } + #updateScrollButtons() { + if (this._gridElement.scrollLeft > 0) { + this._moreButtonLeftElement.style.display = 'inline-flex'; + this.setAttribute('show-left-gradient', ''); + } else { + this._moreButtonLeftElement.style.display = 'none'; + this.removeAttribute('show-left-gradient'); } - if (this.#hiddenTabElements.length === 0) { - // Hide more button: - this._moreButtonElement.style.display = 'none'; - // close the popover - this._popoverContainerElement.hidePopover(); + // Check if the grid is scrolled all the way to the right or if it doesn't need to scroll + if ( + this._gridElement.scrollLeft + this._gridElement.clientWidth < + this._gridElement.scrollWidth + ) { + this._moreButtonRightElement.style.display = 'inline-flex'; + this.setAttribute('show-right-gradient', ''); } else { - // Show more button: - this._moreButtonElement.style.display = ''; + this._moreButtonRightElement.style.display = 'none'; + this.removeAttribute('show-right-gradient'); } - - hasActiveTabInDropdown - ? this._moreButtonElement.classList.add('active-inside') - : this._moreButtonElement.classList.remove('active-inside'); - - this.requestUpdate(); } - #isElementTabLike(el: any): el is UUITabElement { - return ( - typeof el === 'object' && 'active' in el && typeof el.active === 'boolean' - ); + #updateScrollButtonsAfterScroll() { + // We are using a setTimeout mechanism to update after a scroll event + // This is because scrollend event isn't supported by Safari at time of writing + if (this.#scrollTimeout) { + clearTimeout(this.#scrollTimeout); + } + this.#scrollTimeout = window.setTimeout(() => { + this.#updateScrollButtons(); + }, 10); } render() { return html`
-
+ < + +
- -
- ${repeat(this.#hiddenTabElements, el => html`${el}`)} -
-
`; } @@ -306,79 +187,47 @@ export class UUITabGroupElement extends LitElement { } #grid { - width: 1fr; display: flex; height: 100%; min-height: 48px; - overflow: hidden; - text-wrap: nowrap; + overflow-x: auto; + white-space: nowrap; color: var(--uui-tab-text); gap: var(--uui-tab-group-gap, 0); - } - - #popover-container { - --uui-tab-text: var(--uui-tab-group-dropdown-tab-text, unset); - --uui-tab-text-hover: var( - --uui-tab-group-dropdown-tab-text-hover, - unset - ); - --uui-tab-text-active: var( - --uui-tab-group-dropdown-tab-text-active, - unset - ); + scroll-snap-type: x mandatory; + flex-grow: 1; } ::slotted(*:not(:last-of-type)) { border-right: 1px solid var(--uui-tab-divider, none); } - .hidden-tab { - width: 100%; + :host([show-left-gradient]) #grid { + mask-image: linear-gradient( + to right, + transparent, + black 10%, + black 100% + ); } - #hidden-tabs-container { - width: fit-content; - display: flex; - flex-direction: column; - background-color: var( - --uui-tab-group-dropdown-background, - var(--uui-color-surface) + :host([show-right-gradient]) #grid { + mask-image: linear-gradient( + to left, + transparent, + black 10%, + black 100% ); - border-radius: var(--uui-border-radius); - box-shadow: var(--uui-shadow-depth-3); - overflow: hidden; } - :host([dropdown-direction='horizontal']) #hidden-tabs-container { - flex-direction: row; - } - - #more-button { - position: relative; - --uui-button-contrast: var(--uui-tab-text); - --uui-button-contrast-hover: var(--uui-tab-text-hover); - --uui-button-background-color: transparent; - --uui-button-background-color-hover: transparent; - } - #more-button::before { - content: ''; - position: absolute; - bottom: 0; - width: 100%; - background-color: var(--uui-color-current); - height: 0px; - border-radius: 3px 3px 0 0; - opacity: 0; - transition: - opacity ease-in 120ms, - height ease-in 120ms; - } - #more-button.active-inside::before { - opacity: 1; - height: 4px; - transition: - opacity 120ms, - height ease-out 120ms; + :host([show-left-gradient][show-right-gradient]) #grid { + mask-image: linear-gradient( + to right, + transparent, + black 10%, + black 90%, + transparent + ); } `, ]; diff --git a/packages/uui-tabs/lib/uui-tab.element.ts b/packages/uui-tabs/lib/uui-tab.element.ts index 150528a80..83342975a 100644 --- a/packages/uui-tabs/lib/uui-tab.element.ts +++ b/packages/uui-tabs/lib/uui-tab.element.ts @@ -89,7 +89,8 @@ export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) { this.target === '_blank' ? 'noopener noreferrer' : undefined, ), )} - role="tab"> + role="tab" + tabindex="0"> ${this.renderLabel()} @@ -100,7 +101,8 @@ export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) { type="button" id="button" ?disabled=${this.disabled} - role="tab"> + role="tab" + tabindex="0"> ${this.renderLabel()} diff --git a/packages/uui-tabs/lib/uui-tabs.test.ts b/packages/uui-tabs/lib/uui-tabs.test.ts index a73c50b63..8bad9a71b 100644 --- a/packages/uui-tabs/lib/uui-tabs.test.ts +++ b/packages/uui-tabs/lib/uui-tabs.test.ts @@ -1,4 +1,4 @@ -import { expect, fixture, html, oneEvent } from '@open-wc/testing'; +import { expect, fixture, html, oneEvent, aTimeout } from '@open-wc/testing'; import { UUITabGroupElement } from './uui-tab-group.element'; import { UUITabElement } from './uui-tab.element'; @@ -7,6 +7,19 @@ import '@umbraco-ui/uui-button/lib'; import '@umbraco-ui/uui-popover-container/lib'; import '@umbraco-ui/uui-symbol-more/lib'; +async function waitFor(expectation: () => void, timeout = 2000, interval = 50) { + const endTime = Date.now() + timeout; + while (Date.now() < endTime) { + try { + expectation(); + return; + } catch (error) { + await aTimeout(interval); + } + } + throw new Error('waitFor: condition not met within timeout'); +} + describe('UuiTab', () => { let element: UUITabGroupElement; let tabs: UUITabElement[]; @@ -57,4 +70,121 @@ describe('UuiTab', () => { it('tab element passes the a11y audit', async () => { await expect(tabs[0]).shadowDom.to.be.accessible(); }); + + it('passes the a11y audit when tabs overflow the viewport', async () => { + const overflowElement = await fixture(html` + + + + + + + + `); + + await expect(overflowElement).shadowDom.to.be.accessible(); + }); + + it('shows the "more tabs to the right" button when tabs overflow the viewport', async () => { + const overflowElement = await fixture(html` + + + + + + + + `); + + const scrollRightButton = + overflowElement.shadowRoot!.getElementById('more-button-right')!; + + // Check if the "more tabs to the right" button is visible + const buttonRect = scrollRightButton.getBoundingClientRect(); + const isVisible = + buttonRect.width > 0 && + buttonRect.height > 0 && + buttonRect.top >= 0 && + buttonRect.left >= 0; + + expect(isVisible).to.be.true; + }); + + it('doesnt show the "more tabs to the right" button when tabs dont overflow the viewport', async () => { + const overflowElement = await fixture(html` + + + + + `); + + const scrollRightButton = + overflowElement.shadowRoot!.getElementById('more-button-right')!; + + // Check if the "more tabs to the right" button is visible + const buttonRect = scrollRightButton.getBoundingClientRect(); + const isVisible = + buttonRect.width > 0 && + buttonRect.height > 0 && + buttonRect.top >= 0 && + buttonRect.left >= 0; + + expect(isVisible).to.be.false; + }); + + it('doesnt show the "more tabs to the left" button when we havent scrolled right', async () => { + const overflowElement = await fixture(html` + + + + + + + + `); + + const scrollLeftButton = + overflowElement.shadowRoot!.getElementById('more-button-left')!; + + // Check if the "more tabs to the left" button is visible + const buttonRect = scrollLeftButton.getBoundingClientRect(); + const isVisible = + buttonRect.width > 0 && + buttonRect.height > 0 && + buttonRect.top >= 0 && + buttonRect.left >= 0; + + expect(isVisible).to.be.false; + }); + + it('does show the "more tabs to the left" button when we have scrolled right', async () => { + const overflowElement = await fixture(html` + + + + + + + + `); + + const grid = overflowElement.shadowRoot!.getElementById('grid')!; + + // Scroll to the right + grid.scrollBy({ left: 100 }); + + const scrollLeftButton = + overflowElement.shadowRoot!.getElementById('more-button-left')!; + + // Wait for the "more tabs to the left" button to become visible + await waitFor(() => { + const buttonRect = scrollLeftButton.getBoundingClientRect(); + const isVisible = + buttonRect.width > 0 && + buttonRect.height > 0 && + buttonRect.top >= 0 && + buttonRect.left >= 0; + expect(isVisible).to.be.true; + }); + }); });