diff --git a/packages/uui-tabs/lib/uui-tab-group.element.ts b/packages/uui-tabs/lib/uui-tab-group.element.ts index b66462f99..d19855691 100644 --- a/packages/uui-tabs/lib/uui-tab-group.element.ts +++ b/packages/uui-tabs/lib/uui-tab-group.element.ts @@ -26,12 +26,13 @@ export class UUITabGroupElement extends LitElement { private _popoverContainerElement!: UUIPopoverContainerElement; @query('#main') private _mainElement!: HTMLElement; + @query('#grid') private _gridElement!: HTMLElement; @queryAssignedElements({ flatten: true, selector: 'uui-tab, [uui-tab], [role=tab]', }) - private _slottedNodes?: HTMLElement[]; + private _slottedNodes?: UUITabElement[]; /** Stores the current gap used in the breakpoints */ #currentGap = 0; @@ -49,7 +50,7 @@ export class UUITabGroupElement extends LitElement { }) dropdownContentDirection: 'vertical' | 'horizontal' = 'vertical'; - #tabElements: HTMLElement[] = []; + #tabElements: UUITabElement[] = []; #hiddenTabElements: UUITabElement[] = []; #hiddenTabElementsMap: Map = new Map(); @@ -64,14 +65,74 @@ export class UUITabGroupElement extends LitElement { connectedCallback() { super.connectedCallback(); this.#initialize(); + this.addEventListener('keydown', this.#onKeyDown); } disconnectedCallback() { super.disconnectedCallback(); - this.#resizeObserver.unobserve(this); + this.#resizeObserver.unobserve(this._mainElement); this.#cleanupTabs(); + this.removeEventListener('keydown', this.#onKeyDown); } + #setFocusable(tab: UUITabElement | null, focus: boolean = false) { + if (tab) { + // Reset tabindex for all tabs + this.#tabElements.forEach(t => { + if (t === tab) { + t.setFocusable(focus); + } else { + t.removeFocusable(); + } + }); + } + } + + #onKeyDown = (event: KeyboardEvent) => { + const tabs = this.#tabElements; + if (!tabs.length) return; + + const currentIndex = tabs.findIndex(tab => tab.hasFocus() === true); + + let newIndex = -1; + let trigger = false; + + switch (event.key) { + case 'ArrowRight': + newIndex = (currentIndex + 1) % tabs.length; + break; + case 'ArrowLeft': + newIndex = (currentIndex - 1 + tabs.length) % tabs.length; + break; + case 'Home': + newIndex = 0; + break; + case 'End': + newIndex = tabs.length - 1; + break; + case ' ': // Space + case 'Enter': + newIndex = currentIndex; + trigger = true; + break; + + default: + return; + } + + event.preventDefault(); + if (newIndex !== -1) { + const newTab = tabs[newIndex]; + newTab.style.display = 'block'; + this.#setFocusable(newTab, true); + this.#calculateBreakPoints(); + + if (trigger) { + newTab.trigger(); + } + } + }; + async #initialize() { demandCustomElement(this, 'uui-button'); demandCustomElement(this, 'uui-popover-container'); @@ -103,9 +164,7 @@ export class UUITabGroupElement extends LitElement { this.#visibilityBreakpoints.length = 0; } - #onSlotChange() { - this.#cleanupTabs(); - + async #onSlotChange() { this.#setTabArray(); this.#tabElements.forEach(el => { @@ -116,6 +175,8 @@ export class UUITabGroupElement extends LitElement { observer.observe(el); this.#tabResizeObservers.push(observer); }); + + await this.#setInitialFocusable(); } #onTabClicked = (e: MouseEvent) => { @@ -163,7 +224,6 @@ export class UUITabGroupElement extends LitElement { }); // 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( @@ -193,6 +253,13 @@ export class UUITabGroupElement extends LitElement { } #updateCollapsibleTabs(containerWidth: number) { + this._gridElement.scrollLeft = 0; + + // Reset translations for all tabs + this.#tabElements.forEach(tab => { + tab.style.transform = ''; + }); + const moreButtonWidth = this._moreButtonElement.offsetWidth; const containerWithoutButtonWidth = @@ -235,13 +302,43 @@ export class UUITabGroupElement extends LitElement { this.#hiddenTabElements.push(proxyTab); - tab.style.display = 'none'; if (tab.active) { hasActiveTabInDropdown = true; } } } + const hiddenTabHasFocus = this.#tabElements.some(tab => { + return this.#hiddenTabElementsMap.get(tab) && tab.hasFocus(); + }); + + this.#tabElements.forEach(tab => { + if (this.#hiddenTabElementsMap.get(tab)) { + tab.style.transform = hiddenTabHasFocus ? '' : 'translateX(2000%)'; + } + }); + + // If a hidden tab has focus, make sure it is in view + if (hiddenTabHasFocus) { + const focusedTab = this.#tabElements.find( + tab => this.#hiddenTabElementsMap.get(tab) && tab.hasFocus(), + ); + if (focusedTab) { + const containerRect = this._gridElement.getBoundingClientRect(); + const focusedTabRect = focusedTab.getBoundingClientRect(); + const focusedTabWidth = focusedTabRect.width; + const gridWidth = containerRect.width; + + const desiredScrollLeft = + focusedTabRect.left - (gridWidth - focusedTabWidth); + + this._gridElement.scrollLeft = Math.max( + this._gridElement.scrollLeft, + desiredScrollLeft, + ); + } + } + if (this.#hiddenTabElements.length === 0) { // Hide more button: this._moreButtonElement.style.display = 'none'; @@ -267,6 +364,24 @@ export class UUITabGroupElement extends LitElement { ); } + async #setInitialFocusable(): Promise { + // Set initial focus on the active, none hidden tab or the first tab + let initialTab: UUITabElement | undefined; + + const activeTab = this.#tabElements.find(tab => tab.active); + + if (activeTab && !this.#hiddenTabElementsMap.has(activeTab)) { + initialTab = activeTab; + } else if (this.#tabElements.length > 0) { + initialTab = this.#tabElements[0]; + } + + if (initialTab) { + await initialTab.updateComplete; + this.#setFocusable(initialTab); + } + } + render() { return html`
@@ -278,6 +393,7 @@ export class UUITabGroupElement extends LitElement { style="display: none" id="more-button" label="More" + tabindex="-1" compact> @@ -286,7 +402,7 @@ export class UUITabGroupElement extends LitElement { id="popover-container" popover placement="bottom-end"> -
+
${repeat(this.#hiddenTabElements, el => html`${el}`)}
@@ -305,6 +421,7 @@ export class UUITabGroupElement extends LitElement { display: flex; justify-content: space-between; overflow: hidden; + outline: none; } #grid { diff --git a/packages/uui-tabs/lib/uui-tab.element.ts b/packages/uui-tabs/lib/uui-tab.element.ts index 150528a80..eaae11ad0 100644 --- a/packages/uui-tabs/lib/uui-tab.element.ts +++ b/packages/uui-tabs/lib/uui-tab.element.ts @@ -64,11 +64,24 @@ export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) { @property({ type: String, reflect: true }) public orientation?: 'horizontal' | 'vertical' = 'horizontal'; + #focus: boolean; + constructor() { super(); this.addEventListener('click', this.onHostClick); + this.addEventListener('focus', this.#onFocus); + this.addEventListener('blur', this.#onBlur); + this.#focus = false; } + #onFocus = () => { + this.#focus = true; + }; + + #onBlur = () => { + this.#focus = false; + }; + private onHostClick(e: MouseEvent) { if (this.disabled) { e.preventDefault(); @@ -76,11 +89,80 @@ export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) { } } + public trigger() { + if (!this.disabled) { + if (this.href) { + // Find the anchor element within the tab's shadow DOM + const anchor = this.shadowRoot?.querySelector('a'); + + if (anchor) { + // Simulate a native click on the anchor element + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + composed: true, + }); + + anchor.dispatchEvent(clickEvent); + } + } else { + this.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window, + composed: true, + }), + ); + } + } + } + + /** + * Set this tab to be in focusable. + * + * @param {boolean} setFocus - Optional. If `true`, explicitly sets focus on the button. Defaults to `false`. + */ + public setFocusable(setFocus: boolean = false) { + const button: HTMLElement | null | undefined = + this.shadowRoot?.querySelector('#button'); + if (setFocus) { + button?.focus(); + } + button?.setAttribute('tabindex', '0'); + } + + /** + * Remove the ability to focus this tab. + */ + public removeFocusable() { + const button = this.shadowRoot?.querySelector('#button'); + button?.setAttribute('tabindex', '-1'); + } + + /** + * Returns true if the tab has focus. + * @type {boolean} + * @attr + * @default false + */ + public hasFocus() { + const button = this.shadowRoot?.querySelector('#button'); + return ( + this.#focus || + document.activeElement === button || + document.activeElement === this + ); + } + render() { return this.href ? html` + )}> ${this.renderLabel()} @@ -100,6 +181,7 @@ export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) { type="button" id="button" ?disabled=${this.disabled} + tabindex="-1" role="tab"> ${this.renderLabel()} diff --git a/packages/uui-tabs/lib/uui-tabs.test.ts b/packages/uui-tabs/lib/uui-tabs.test.ts index d69439fa2..6e3f02d86 100644 --- a/packages/uui-tabs/lib/uui-tabs.test.ts +++ b/packages/uui-tabs/lib/uui-tabs.test.ts @@ -14,9 +14,9 @@ describe('UuiTab', () => { beforeEach(async () => { element = await fixture(html` - Content - Packages - Media + Content + Packages + Media Content to force a more button Content to force a more button Content to force a more button @@ -59,7 +59,7 @@ describe('UuiTab', () => { }); it('it emits a click event', async () => { - const listener = oneEvent(element, 'click', false); + const listener = oneEvent(element, 'click'); tabs[0].click(); const ev = await listener; expect(ev.type).to.equal('click'); @@ -72,4 +72,183 @@ describe('UuiTab', () => { it('tab element passes the a11y audit', async () => { await expect(tabs[0]).shadowDom.to.be.accessible(); }); + + it('focuses and activates next tab on ArrowRight', async () => { + tabs[2].setFocusable(true); // Focus the tab group + await element.updateComplete; + + const event = new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + composed: true, + }); + + element.dispatchEvent(event); + await element.updateComplete; + + expect(tabs[2].hasFocus()).to.be.false; + expect(tabs[3].hasFocus()).to.be.true; + }); + + it('focuses and activates previous tab on ArrowLeft', async () => { + tabs[2].setFocusable(true); + await element.updateComplete; + + const event = new KeyboardEvent('keydown', { + key: 'ArrowLeft', + bubbles: true, + composed: true, + }); + + element.dispatchEvent(event); + await element.updateComplete; + + expect(tabs[1].hasFocus()).to.be.true; + expect(tabs[2].hasFocus()).to.be.false; + }); + + it('focuses and activates first tab on Home', async () => { + tabs[2].setFocusable(true); + await element.updateComplete; + + const event = new KeyboardEvent('keydown', { + key: 'Home', + bubbles: true, + composed: true, + }); + + element.dispatchEvent(event); + await element.updateComplete; + + expect(tabs[0].hasFocus()).to.be.true; + expect(tabs[2].hasFocus()).to.be.false; + }); + + it('focuses and activates last tab on End', async () => { + tabs[2].focus(); + await element.updateComplete; + + const event = new KeyboardEvent('keydown', { + key: 'End', + bubbles: true, + composed: true, + }); + + element.dispatchEvent(event); + await element.updateComplete; + + expect(tabs[2].hasFocus()).to.be.false; + expect(tabs[17].hasFocus()).to.be.true; + }); + + it('wraps focus from last to first tab with ArrowRight', async () => { + tabs[2].setFocusable(true); + await element.updateComplete; + + // Set focus to last tab + const event = new KeyboardEvent('keydown', { + key: 'End', + bubbles: true, + composed: true, + }); + + element.dispatchEvent(event); + await element.updateComplete; + + const event2 = new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + composed: true, + }); + + element.dispatchEvent(event2); + await element.updateComplete; + + expect(tabs[0].hasFocus()).to.be.true; + expect(tabs[17].hasFocus()).to.be.false; + }); + + it('activates the focused tab on Space or Enter', async () => { + tabs[2].setFocusable(true); + await element.updateComplete; + + const event = new KeyboardEvent('keydown', { + key: 'ArrowLeft', // Move focus to the second tab + bubbles: true, + composed: true, + }); + element.dispatchEvent(event); + await element.updateComplete; + + const spaceEvent = new KeyboardEvent('keydown', { + key: ' ', // Simulate Space key press + bubbles: true, + composed: true, + }); + element.dispatchEvent(spaceEvent); + await element.updateComplete; + + await new Promise(resolve => { + const hashChangeListener = () => { + if (window.location.hash === '#packages') { + window.removeEventListener('hashchange', hashChangeListener); + resolve(); + } + }; + window.addEventListener('hashchange', hashChangeListener); + }); + + expect(tabs[0].active).to.be.false; + expect(tabs[1].active).to.be.true; + expect(window.location.hash).to.equal('#packages'); + + const arrowLeftEvent = new KeyboardEvent('keydown', { + // Move focus to the first tab + key: 'ArrowLeft', + bubbles: true, + composed: true, + }); + element.dispatchEvent(arrowLeftEvent); + await element.updateComplete; + + const enterEvent = new KeyboardEvent('keydown', { + key: 'Enter', // Simulate Enter key press + bubbles: true, + composed: true, + }); + element.dispatchEvent(enterEvent); + await element.updateComplete; + + await new Promise(resolve => { + const hashChangeListener = () => { + if (window.location.hash === '#content') { + window.removeEventListener('hashchange', hashChangeListener); + resolve(); + } + }; + window.addEventListener('hashchange', hashChangeListener); + }); + + expect(tabs[0].active).to.be.true; + expect(tabs[1].active).to.be.false; + expect(window.location.hash).to.equal('#content'); + }); + + it('does not focus the first tab on initialization, only sets tabindex="0"', async () => { + element = await fixture(html` + + Content + Packages + Media + + `); + + tabs = Array.from(element.querySelectorAll('uui-tab')); + const firstTabButton = tabs[0].shadowRoot?.querySelector( + '#button', + ) as HTMLButtonElement; + + expect(tabs[0].hasFocus()).to.be.false; // Assert that the button is not focused + expect(firstTabButton.getAttribute('tabindex')).to.equal('0'); // Assert that the tabindex is set to 0 + }); });