From 765be8bd581517a9a0f281ac345edd5a39028829 Mon Sep 17 00:00:00 2001 From: Jonny Muir Date: Wed, 14 May 2025 22:00:46 +0000 Subject: [PATCH 1/8] feat: enhance tab navigation with keyboard support and focus management to adhere to ARIA Authoring Practices Guide --- .../uui-tabs/lib/uui-tab-group.element.ts | 76 ++++++++++- packages/uui-tabs/lib/uui-tabs.test.ts | 121 ++++++++++++++++++ 2 files changed, 191 insertions(+), 6 deletions(-) diff --git a/packages/uui-tabs/lib/uui-tab-group.element.ts b/packages/uui-tabs/lib/uui-tab-group.element.ts index b66462f99..4b7dfc5cc 100644 --- a/packages/uui-tabs/lib/uui-tab-group.element.ts +++ b/packages/uui-tabs/lib/uui-tab-group.element.ts @@ -31,7 +31,7 @@ export class UUITabGroupElement extends LitElement { 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 +49,7 @@ export class UUITabGroupElement extends LitElement { }) dropdownContentDirection: 'vertical' | 'horizontal' = 'vertical'; - #tabElements: HTMLElement[] = []; + #tabElements: UUITabElement[] = []; #hiddenTabElements: UUITabElement[] = []; #hiddenTabElementsMap: Map = new Map(); @@ -64,14 +64,70 @@ 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); } + firstUpdated() { + // Set initial focus on the active tab or the first tab + const activeTab = this.#tabElements.find(tab => tab.active); + if (activeTab) { + this.#setFocus(activeTab); + } else if (this.#tabElements.length > 0) { + this.#setFocus(this.#tabElements[0]); + } + } + + #setFocus(tab: UUITabElement | null) { + if (tab) { + (tab.shadowRoot?.querySelector('#button') as HTMLElement)?.focus(); + } + } + + #onKeyDown = (event: KeyboardEvent) => { + const tabs = this.#tabElements.filter(this.#isElementTabLike); + if (!tabs.length) return; + + const currentIndex = tabs.findIndex(tab => tab.active === true); + + let newIndex = -1; + 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; + default: + return; + } + + event.preventDefault(); + if (newIndex !== -1) { + // Deactivate current tab + if (currentIndex !== -1) { + tabs[currentIndex].active = false; + } + + const newTab = tabs[newIndex]; + this.#setFocus(newTab); + newTab.active = true; // Activate new tab + this.#onTabClicked({ currentTarget: newTab } as any as MouseEvent); + } + }; + async #initialize() { demandCustomElement(this, 'uui-button'); demandCustomElement(this, 'uui-popover-container'); @@ -104,8 +160,6 @@ export class UUITabGroupElement extends LitElement { } #onSlotChange() { - this.#cleanupTabs(); - this.#setTabArray(); this.#tabElements.forEach(el => { @@ -269,7 +323,16 @@ export class UUITabGroupElement extends LitElement { render() { return html` -
+
{ + if (this.#tabElements.length > 0) { + this.#setFocus( + this.#tabElements.find(tab => tab.active) || this.#tabElements[0], + ); + } + }}>
@@ -305,6 +368,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-tabs.test.ts b/packages/uui-tabs/lib/uui-tabs.test.ts index d69439fa2..fc274e56b 100644 --- a/packages/uui-tabs/lib/uui-tabs.test.ts +++ b/packages/uui-tabs/lib/uui-tabs.test.ts @@ -72,4 +72,125 @@ 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 () => { + element.focus(); // 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].active).to.be.false; + expect(tabs[3].active).to.be.true; + }); + + it('focuses and activates previous tab on ArrowLeft', async () => { + element.focus(); + await element.updateComplete; + + const event = new KeyboardEvent('keydown', { + key: 'ArrowLeft', + bubbles: true, + composed: true, + }); + + element.dispatchEvent(event); + await element.updateComplete; + + expect(tabs[1].active).to.be.true; + expect(tabs[2].active).to.be.false; + }); + + it('focuses and activates first tab on Home', async () => { + element.focus(); + await element.updateComplete; + + const event = new KeyboardEvent('keydown', { + key: 'Home', + bubbles: true, + composed: true, + }); + + element.dispatchEvent(event); + await element.updateComplete; + + expect(tabs[0].active).to.be.true; + expect(tabs[2].active).to.be.false; + }); + + it('focuses and activates last tab on End', async () => { + element.focus(); + await element.updateComplete; + + const event = new KeyboardEvent('keydown', { + key: 'End', + bubbles: true, + composed: true, + }); + + element.dispatchEvent(event); + await element.updateComplete; + + expect(tabs[2].active).to.be.false; + expect(tabs[17].active).to.be.true; + }); + + it('wraps focus from last to first tab with ArrowRight', async () => { + element.focus(); + 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].active).to.be.true; + expect(tabs[17].active).to.be.false; + }); + + it('wraps focus from first to last tab with ArrowLeft', async () => { + element.focus(); + await element.updateComplete; + + const event = new KeyboardEvent('keydown', { + key: 'Home', + bubbles: true, + composed: true, + }); + + element.dispatchEvent(event); + await element.updateComplete; + + const event2 = new KeyboardEvent('keydown', { + key: 'ArrowLeft', + bubbles: true, + composed: true, + }); + + element.dispatchEvent(event2); + await element.updateComplete; + + expect(tabs[0].active).to.be.false; + expect(tabs[17].active).to.be.true; + }); }); From 6f6a44e4c2b4e8dbe08535d3436a94dcd3b42446 Mon Sep 17 00:00:00 2001 From: Jonny Muir Date: Thu, 15 May 2025 21:47:40 +0000 Subject: [PATCH 2/8] feat: add popover visibility on keyboard navigation based on active tabs in dropdown --- .../uui-tabs/lib/uui-tab-group.element.ts | 10 ++++++++ packages/uui-tabs/lib/uui-tabs.test.ts | 23 ++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/uui-tabs/lib/uui-tab-group.element.ts b/packages/uui-tabs/lib/uui-tab-group.element.ts index 4b7dfc5cc..0dcc82962 100644 --- a/packages/uui-tabs/lib/uui-tab-group.element.ts +++ b/packages/uui-tabs/lib/uui-tab-group.element.ts @@ -125,6 +125,16 @@ export class UUITabGroupElement extends LitElement { this.#setFocus(newTab); newTab.active = true; // Activate new tab this.#onTabClicked({ currentTarget: newTab } as any as MouseEvent); + + // Check if there are any active tabs in the dropdown + const hasActiveHidden = this.#hiddenTabElements.some( + el => el.active && el !== newTab, + ); + if (hasActiveHidden) { + this._popoverContainerElement.showPopover(); + } else { + this._popoverContainerElement.hidePopover(); + } } }; diff --git a/packages/uui-tabs/lib/uui-tabs.test.ts b/packages/uui-tabs/lib/uui-tabs.test.ts index fc274e56b..a909782ac 100644 --- a/packages/uui-tabs/lib/uui-tabs.test.ts +++ b/packages/uui-tabs/lib/uui-tabs.test.ts @@ -2,6 +2,7 @@ import { expect, fixture, html, oneEvent } from '@open-wc/testing'; import { UUITabGroupElement } from './uui-tab-group.element'; import { UUITabElement } from './uui-tab.element'; +import { UUIPopOverContainer } from '@umbraco-ui/uui-popover-container/lib'; import '@umbraco-ui/uui-button/lib'; import '@umbraco-ui/uui-popover-container/lib'; @@ -10,6 +11,7 @@ import '@umbraco-ui/uui-symbol-more/lib'; describe('UuiTab', () => { let element: UUITabGroupElement; let tabs: UUITabElement[]; + let popoverContainer: UUIPopOverContainer; beforeEach(async () => { element = await fixture(html` @@ -36,6 +38,9 @@ describe('UuiTab', () => { `); tabs = Array.from(element.querySelectorAll('uui-tab')); + popoverContainer = element.shadowRoot?.querySelector( + '#popover-container', + ) as UUIPopOverContainer; }); it('is defined as its own instance', () => { @@ -168,7 +173,7 @@ describe('UuiTab', () => { expect(tabs[17].active).to.be.false; }); - it('wraps focus from first to last tab with ArrowLeft', async () => { + it('wraps focus from first to last tab with ArrowLeft and popmenu appears', async () => { element.focus(); await element.updateComplete; @@ -181,6 +186,7 @@ describe('UuiTab', () => { element.dispatchEvent(event); await element.updateComplete; + // Check we loop round and the popover appears const event2 = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, @@ -192,5 +198,20 @@ describe('UuiTab', () => { expect(tabs[0].active).to.be.false; expect(tabs[17].active).to.be.true; + expect(popoverContainer.open).to.be.true; + + // Check the popup is hidden when the ArrowRight key is pressed + const event3 = new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + composed: true, + }); + + element.dispatchEvent(event3); + await element.updateComplete; + + expect(tabs[0].active).to.be.true; + expect(tabs[17].active).to.be.false; + expect(popoverContainer.open).to.be.false; }); }); From c663dc5be292b262dda68cc707f06c06c5b4dc4f Mon Sep 17 00:00:00 2001 From: Jonny Muir Date: Mon, 26 May 2025 21:24:04 +0000 Subject: [PATCH 3/8] feat: enhance tab functionality with focus management and trigger support for keyboard navigation --- .../uui-tabs/lib/uui-tab-group.element.ts | 126 ++++++++++++------ packages/uui-tabs/lib/uui-tab.element.ts | 69 +++++++++- packages/uui-tabs/lib/uui-tabs.test.ts | 89 ++++++++----- 3 files changed, 207 insertions(+), 77 deletions(-) diff --git a/packages/uui-tabs/lib/uui-tab-group.element.ts b/packages/uui-tabs/lib/uui-tab-group.element.ts index 0dcc82962..a903a03ee 100644 --- a/packages/uui-tabs/lib/uui-tab-group.element.ts +++ b/packages/uui-tabs/lib/uui-tab-group.element.ts @@ -26,6 +26,7 @@ export class UUITabGroupElement extends LitElement { private _popoverContainerElement!: UUIPopoverContainerElement; @query('#main') private _mainElement!: HTMLElement; + @query('#grid') private _gridElement!: HTMLElement; @queryAssignedElements({ flatten: true, @@ -75,28 +76,31 @@ export class UUITabGroupElement extends LitElement { } firstUpdated() { - // Set initial focus on the active tab or the first tab - const activeTab = this.#tabElements.find(tab => tab.active); - if (activeTab) { - this.#setFocus(activeTab); - } else if (this.#tabElements.length > 0) { - this.#setFocus(this.#tabElements[0]); - } + this.#setInitialFocus(); } - #setFocus(tab: UUITabElement | null) { + #setFocusable(tab: UUITabElement | null, focus: boolean = true) { if (tab) { - (tab.shadowRoot?.querySelector('#button') as HTMLElement)?.focus(); + // 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.filter(this.#isElementTabLike); + const tabs = this.#tabElements; if (!tabs.length) return; - const currentIndex = tabs.findIndex(tab => tab.active === true); + const currentIndex = tabs.findIndex(tab => tab.hasFocus() === true); let newIndex = -1; + let trigger = false; + switch (event.key) { case 'ArrowRight': newIndex = (currentIndex + 1) % tabs.length; @@ -110,30 +114,25 @@ export class UUITabGroupElement extends LitElement { case 'End': newIndex = tabs.length - 1; break; + case ' ': // Space + case 'Enter': + newIndex = currentIndex; + trigger = true; + break; + default: return; } event.preventDefault(); if (newIndex !== -1) { - // Deactivate current tab - if (currentIndex !== -1) { - tabs[currentIndex].active = false; - } - const newTab = tabs[newIndex]; - this.#setFocus(newTab); - newTab.active = true; // Activate new tab - this.#onTabClicked({ currentTarget: newTab } as any as MouseEvent); + newTab.style.display = 'block'; + this.#setFocusable(newTab, true); + this.#calculateBreakPoints(); - // Check if there are any active tabs in the dropdown - const hasActiveHidden = this.#hiddenTabElements.some( - el => el.active && el !== newTab, - ); - if (hasActiveHidden) { - this._popoverContainerElement.showPopover(); - } else { - this._popoverContainerElement.hidePopover(); + if (trigger) { + newTab.trigger(); } } }; @@ -180,6 +179,8 @@ export class UUITabGroupElement extends LitElement { observer.observe(el); this.#tabResizeObservers.push(observer); }); + + this.#setInitialFocus(); } #onTabClicked = (e: MouseEvent) => { @@ -227,7 +228,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( @@ -257,6 +257,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 = @@ -299,13 +306,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'; @@ -331,18 +368,26 @@ export class UUITabGroupElement extends LitElement { ); } + #setInitialFocus(): void { + // 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) { + this.#setFocusable(initialTab); + } + } + render() { return html` -
{ - if (this.#tabElements.length > 0) { - this.#setFocus( - this.#tabElements.find(tab => tab.active) || this.#tabElements[0], - ); - } - }}> +
@@ -351,6 +396,7 @@ export class UUITabGroupElement extends LitElement { style="display: none" id="more-button" label="More" + tabindex="-1" compact> @@ -359,7 +405,7 @@ export class UUITabGroupElement extends LitElement { id="popover-container" popover placement="bottom-end"> -
+
${repeat(this.#hiddenTabElements, el => html`${el}`)}
diff --git a/packages/uui-tabs/lib/uui-tab.element.ts b/packages/uui-tabs/lib/uui-tab.element.ts index 150528a80..94b722a01 100644 --- a/packages/uui-tabs/lib/uui-tab.element.ts +++ b/packages/uui-tabs/lib/uui-tab.element.ts @@ -76,11 +76,76 @@ 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 document.activeElement === button || document.activeElement === this; + } + render() { return this.href ? html` + )}> ${this.renderLabel()} @@ -100,6 +164,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 a909782ac..cae7634ad 100644 --- a/packages/uui-tabs/lib/uui-tabs.test.ts +++ b/packages/uui-tabs/lib/uui-tabs.test.ts @@ -2,7 +2,6 @@ import { expect, fixture, html, oneEvent } from '@open-wc/testing'; import { UUITabGroupElement } from './uui-tab-group.element'; import { UUITabElement } from './uui-tab.element'; -import { UUIPopOverContainer } from '@umbraco-ui/uui-popover-container/lib'; import '@umbraco-ui/uui-button/lib'; import '@umbraco-ui/uui-popover-container/lib'; @@ -11,14 +10,13 @@ import '@umbraco-ui/uui-symbol-more/lib'; describe('UuiTab', () => { let element: UUITabGroupElement; let tabs: UUITabElement[]; - let popoverContainer: UUIPopOverContainer; 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 @@ -38,9 +36,6 @@ describe('UuiTab', () => { `); tabs = Array.from(element.querySelectorAll('uui-tab')); - popoverContainer = element.shadowRoot?.querySelector( - '#popover-container', - ) as UUIPopOverContainer; }); it('is defined as its own instance', () => { @@ -64,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'); @@ -91,8 +86,8 @@ describe('UuiTab', () => { element.dispatchEvent(event); await element.updateComplete; - expect(tabs[2].active).to.be.false; - expect(tabs[3].active).to.be.true; + expect(tabs[2].hasFocus()).to.be.false; + expect(tabs[3].hasFocus()).to.be.true; }); it('focuses and activates previous tab on ArrowLeft', async () => { @@ -108,8 +103,8 @@ describe('UuiTab', () => { element.dispatchEvent(event); await element.updateComplete; - expect(tabs[1].active).to.be.true; - expect(tabs[2].active).to.be.false; + expect(tabs[1].hasFocus()).to.be.true; + expect(tabs[2].hasFocus()).to.be.false; }); it('focuses and activates first tab on Home', async () => { @@ -125,8 +120,8 @@ describe('UuiTab', () => { element.dispatchEvent(event); await element.updateComplete; - expect(tabs[0].active).to.be.true; - expect(tabs[2].active).to.be.false; + expect(tabs[0].hasFocus()).to.be.true; + expect(tabs[2].hasFocus()).to.be.false; }); it('focuses and activates last tab on End', async () => { @@ -142,8 +137,8 @@ describe('UuiTab', () => { element.dispatchEvent(event); await element.updateComplete; - expect(tabs[2].active).to.be.false; - expect(tabs[17].active).to.be.true; + 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 () => { @@ -169,49 +164,73 @@ describe('UuiTab', () => { element.dispatchEvent(event2); await element.updateComplete; - expect(tabs[0].active).to.be.true; - expect(tabs[17].active).to.be.false; + expect(tabs[0].hasFocus()).to.be.true; + expect(tabs[17].hasFocus()).to.be.false; }); - it('wraps focus from first to last tab with ArrowLeft and popmenu appears', async () => { + it('activates the focused tab on Space or Enter', async () => { element.focus(); await element.updateComplete; const event = new KeyboardEvent('keydown', { - key: 'Home', + key: 'ArrowLeft', // Move focus to the second tab bubbles: true, composed: true, }); - element.dispatchEvent(event); await element.updateComplete; - // Check we loop round and the popover appears - const event2 = new KeyboardEvent('keydown', { - key: 'ArrowLeft', + const spaceEvent = new KeyboardEvent('keydown', { + key: ' ', // Simulate Space key press bubbles: true, composed: true, }); - - element.dispatchEvent(event2); + 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[17].active).to.be.true; - expect(popoverContainer.open).to.be.true; + expect(tabs[1].active).to.be.true; + expect(window.location.hash).to.equal('#packages'); - // Check the popup is hidden when the ArrowRight key is pressed - const event3 = new KeyboardEvent('keydown', { - key: 'ArrowRight', + const arrowLeftEvent = new KeyboardEvent('keydown', { + // Move focus to the first tab + key: 'ArrowLeft', bubbles: true, composed: true, }); + element.dispatchEvent(arrowLeftEvent); + await element.updateComplete; - element.dispatchEvent(event3); + 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[17].active).to.be.false; - expect(popoverContainer.open).to.be.false; + expect(tabs[1].active).to.be.false; + expect(window.location.hash).to.equal('#content'); }); }); From 500f8ce4b9badfde06000323551030497a708a82 Mon Sep 17 00:00:00 2001 From: Jonny Muir Date: Tue, 27 May 2025 06:08:25 +0000 Subject: [PATCH 4/8] refactor: rename #setInitialFocus to #setInitialFocusable and remove unnecessary calls --- packages/uui-tabs/lib/uui-tab-group.element.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/uui-tabs/lib/uui-tab-group.element.ts b/packages/uui-tabs/lib/uui-tab-group.element.ts index a903a03ee..91558a27b 100644 --- a/packages/uui-tabs/lib/uui-tab-group.element.ts +++ b/packages/uui-tabs/lib/uui-tab-group.element.ts @@ -75,10 +75,6 @@ export class UUITabGroupElement extends LitElement { this.removeEventListener('keydown', this.#onKeyDown); } - firstUpdated() { - this.#setInitialFocus(); - } - #setFocusable(tab: UUITabElement | null, focus: boolean = true) { if (tab) { // Reset tabindex for all tabs @@ -180,7 +176,7 @@ export class UUITabGroupElement extends LitElement { this.#tabResizeObservers.push(observer); }); - this.#setInitialFocus(); + this.#setInitialFocusable(); } #onTabClicked = (e: MouseEvent) => { @@ -368,7 +364,7 @@ export class UUITabGroupElement extends LitElement { ); } - #setInitialFocus(): void { + #setInitialFocusable(): void { // Set initial focus on the active, none hidden tab or the first tab let initialTab: UUITabElement | undefined; @@ -387,11 +383,12 @@ export class UUITabGroupElement extends LitElement { render() { return html` -
+