Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions packages/ui/packages/registry/components/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UiTabsTrigger>('ui-tabs-trigger'));
// Scope to triggers belonging to THIS group: querySelectorAll crosses into
// a nested <ui-tabs> 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<UiTabsTrigger>('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';
Expand All @@ -257,7 +263,13 @@ export class UiTabsTrigger extends WebComponent({
e.preventDefault();
const v = target.value;
if (v) tabs.setAttribute('value', v);
target.focus();
// Focus the inner <button role="tab">, NOT the <ui-tabs-trigger> host.
// The host carries no tabindex so it is not focusable; calling focus()
// on it is a no-op that leaves the focus ring stranded on the previous
// trigger while a different panel shows. The roving tabindex lives on
// the inner button, so that is what APG focus-follows-selection targets.
const btn = target.querySelector<HTMLButtonElement>('[role="tab"]');
(btn ?? target).focus();
}
};
}
Expand Down
143 changes: 143 additions & 0 deletions packages/ui/test/components/browser/ui-a11y.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,119 @@ suite('ui-tabs a11y', () => {
assert.ok(triggers[0].id !== triggers[1].id, 'ids differ across groups');
root.remove();
});

// Keyboard nav (APG automatic activation): arrow / Home / End both select
// the tab AND move focus to its inner <button role="tab">. The counterfactual
// for the #1078 fix is `document.activeElement === next inner button`: before
// the fix the handler focused the non-focusable <ui-tabs-trigger> host, so
// focus never moved and this assertion failed.
async function mountTabs(orientation = 'horizontal') {
const root = await mount(html`
<ui-tabs value="a" orientation=${orientation}>
<ui-tabs-list>
<ui-tabs-trigger value="a">A</ui-tabs-trigger>
<ui-tabs-trigger value="b">B</ui-tabs-trigger>
<ui-tabs-trigger value="c">C</ui-tabs-trigger>
</ui-tabs-list>
<ui-tabs-content value="a">PANE A</ui-tabs-content>
<ui-tabs-content value="b">PANE B</ui-tabs-content>
<ui-tabs-content value="c">PANE C</ui-tabs-content>
</ui-tabs>
`);
const tabsEl = root.querySelector('ui-tabs');
const btns = [...root.querySelectorAll('ui-tabs-trigger [role="tab"]')];
return { root, tabsEl, btns };
}

const press = (el, key) =>
el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));

test('ArrowRight moves focus AND selection to the next trigger', async () => {
const { root, tabsEl, btns } = await mountTabs();
btns[0].focus();
press(btns[0], 'ArrowRight');
await tick();
assert.equal(document.activeElement, btns[1], 'focus moved to trigger B');
assert.equal(tabsEl.getAttribute('value'), 'b', 'selection followed to b');
assert.equal(btns[1].getAttribute('data-state'), 'active', 'B is active');
assert.equal(btns[1].tabIndex, 0, 'B is the tab stop');
assert.equal(btns[0].tabIndex, -1, 'A left the tab order');
root.remove();
});

test('ArrowLeft wraps from the first trigger to the last', async () => {
const { root, tabsEl, btns } = await mountTabs();
btns[0].focus();
press(btns[0], 'ArrowLeft');
await tick();
assert.equal(document.activeElement, btns[2], 'focus wrapped to trigger C');
assert.equal(tabsEl.getAttribute('value'), 'c', 'selection wrapped to c');
root.remove();
});

test('Home / End move focus to the first / last trigger', async () => {
const { root, tabsEl, btns } = await mountTabs();
btns[1].focus();
press(btns[1], 'End');
await tick();
assert.equal(document.activeElement, btns[2], 'End -> last trigger');
assert.equal(tabsEl.getAttribute('value'), 'c');
press(btns[2], 'Home');
await tick();
assert.equal(document.activeElement, btns[0], 'Home -> first trigger');
assert.equal(tabsEl.getAttribute('value'), 'a');
root.remove();
});

test('arrow nav stays inside its own group when a panel nests another tabs', async () => {
const root = await mount(html`
<ui-tabs value="outer-a">
<ui-tabs-list>
<ui-tabs-trigger value="outer-a">Outer A</ui-tabs-trigger>
<ui-tabs-trigger value="outer-b">Outer B</ui-tabs-trigger>
</ui-tabs-list>
<ui-tabs-content value="outer-a">
<ui-tabs value="inner-x">
<ui-tabs-list>
<ui-tabs-trigger value="inner-x">Inner X</ui-tabs-trigger>
<ui-tabs-trigger value="inner-y">Inner Y</ui-tabs-trigger>
</ui-tabs-list>
<ui-tabs-content value="inner-x">INNER PANE</ui-tabs-content>
<ui-tabs-content value="inner-y">INNER PANE Y</ui-tabs-content>
</ui-tabs>
</ui-tabs-content>
<ui-tabs-content value="outer-b">OUTER PANE B</ui-tabs-content>
</ui-tabs>
`);
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();
press(btns[0], 'ArrowDown');
await tick();
assert.equal(document.activeElement, btns[1], 'ArrowDown -> next trigger');
assert.equal(tabsEl.getAttribute('value'), 'b');
press(btns[1], 'ArrowUp');
await tick();
assert.equal(document.activeElement, btns[0], 'ArrowUp -> previous trigger');
assert.equal(tabsEl.getAttribute('value'), 'a');
root.remove();
});
});

suite('ui-toggle-group a11y', () => {
Expand Down Expand Up @@ -181,6 +294,36 @@ suite('ui-dropdown-menu a11y', () => {
assert.equal(btn.getAttribute('aria-expanded'), 'true', 'open -> true');
root.remove();
});

// Keyboard nav: opening focuses the first item, ArrowDown/Up move focus
// among [role="menuitem"] (skipping disabled), and Escape closes the menu.
test('open focuses first item; ArrowDown/Up move focus; Escape closes', async () => {
const root = await mount(html`
<ui-dropdown-menu>
<ui-dropdown-menu-trigger><button>Options</button></ui-dropdown-menu-trigger>
<ui-dropdown-menu-content>
<ui-dropdown-menu-item>Profile</ui-dropdown-menu-item>
<ui-dropdown-menu-item>Billing</ui-dropdown-menu-item>
<ui-dropdown-menu-item>Settings</ui-dropdown-menu-item>
</ui-dropdown-menu-content>
</ui-dropdown-menu>
`);
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', () => {
Expand Down
Loading