From 30d7ce2e83c6cbc8dd766427562570d6570eef86 Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 24 Jul 2026 17:26:59 +0530 Subject: [PATCH 1/2] fix(ui): move focus to inner button on tabs arrow-key nav Arrow / Home / End on a focused tab trigger changed the selected tab but did not move keyboard focus, stranding the focus ring on the previous trigger while a different panel showed. This broke the WAI-ARIA APG Tabs focus-follows-selection contract for keyboard users. The handler called focus() on the host, which carries no tabindex and so is not focusable; the roving tabindex lives on the inner + + Profile + Billing + Settings + + + `); + const menuEl = root.querySelector('ui-dropdown-menu'); + const items = [...root.querySelectorAll('ui-dropdown-menu-item [role="menuitem"]')]; + menuEl.show(); + await tick(); + assert.equal(document.activeElement, items[0], 'first item focused on open'); + items[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await tick(); + assert.equal(document.activeElement, items[1], 'ArrowDown -> second item'); + items[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + await tick(); + assert.equal(document.activeElement, items[0], 'ArrowUp -> first item'); + items[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + await tick(); + assert.equal(menuEl.open, false, 'Escape closes the menu'); + root.remove(); + }); }); suite('ui-dialog a11y', () => { From b8c0286372e1ca5c359e53371fe372251511efcf Mon Sep 17 00:00:00 2001 From: Vivek Date: Fri, 24 Jul 2026 17:45:35 +0530 Subject: [PATCH 2/2] fix(ui): scope tabs arrow nav to its own group, not nested tabs Review finding on #1078: _onKeyDown collected triggers with tabs.querySelectorAll('ui-tabs-trigger'), which crosses into a nested inside a panel. Arrow nav at the boundary then jumped into the inner group and set the outer group's value to an inner-only value, hiding every outer panel. Filter to triggers whose closest('ui-tabs') is this group, the same scoping dropdown-menu already applies to its items. Adds a nested-groups browser test that fails against the unscoped collection. --- .../ui/packages/registry/components/tabs.ts | 8 ++++- .../test/components/browser/ui-a11y.test.js | 36 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/ui/packages/registry/components/tabs.ts b/packages/ui/packages/registry/components/tabs.ts index a2083f40f..c94995408 100644 --- a/packages/ui/packages/registry/components/tabs.ts +++ b/packages/ui/packages/registry/components/tabs.ts @@ -241,7 +241,13 @@ export class UiTabsTrigger extends WebComponent({ const tabs = this._tabs; if (!tabs) return; const orientation = tabs.orientation; - const triggers = Array.from(tabs.querySelectorAll('ui-tabs-trigger')); + // Scope to triggers belonging to THIS group: querySelectorAll crosses into + // a nested inside a panel, and arrow nav at the boundary would + // jump into the inner group and set this group's value to an inner-only + // value (hiding every panel). Same scoping dropdown-menu applies to items. + const triggers = Array.from(tabs.querySelectorAll('ui-tabs-trigger')).filter( + (t) => t._tabs === tabs, + ); const idx = triggers.indexOf(this); const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown'; const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'; diff --git a/packages/ui/test/components/browser/ui-a11y.test.js b/packages/ui/test/components/browser/ui-a11y.test.js index eeeec57d0..85754c931 100644 --- a/packages/ui/test/components/browser/ui-a11y.test.js +++ b/packages/ui/test/components/browser/ui-a11y.test.js @@ -152,6 +152,42 @@ suite('ui-tabs a11y', () => { root.remove(); }); + test('arrow nav stays inside its own group when a panel nests another tabs', async () => { + const root = await mount(html` + + + Outer A + Outer B + + + + + Inner X + Inner Y + + INNER PANE + INNER PANE Y + + + OUTER PANE B + + `); + const outer = root.querySelector('ui-tabs'); + const outerBtns = [ + root.querySelector('ui-tabs-trigger[value="outer-a"] [role="tab"]'), + root.querySelector('ui-tabs-trigger[value="outer-b"] [role="tab"]'), + ]; + // ArrowRight from the LAST outer trigger must wrap to the FIRST outer + // trigger, not jump into the nested group (which would set the outer + // value to an inner-only value and hide every outer panel). + outerBtns[1].focus(); + press(outerBtns[1], 'ArrowRight'); + await tick(); + assert.equal(document.activeElement, outerBtns[0], 'wrapped within the outer group'); + assert.equal(outer.getAttribute('value'), 'outer-a', 'outer value stays an outer value'); + root.remove(); + }); + test('vertical orientation navigates with ArrowDown / ArrowUp', async () => { const { root, tabsEl, btns } = await mountTabs('vertical'); btns[0].focus();