Skip to content

Fixes #700 - Fix keyboard interaction for tabs as per W3 accessibility pattern #1112

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
Open
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
135 changes: 126 additions & 9 deletions packages/uui-tabs/lib/uui-tab-group.element.ts
Original file line number Diff line number Diff line change
@@ -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<UUITabElement, UUITabElement> = 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<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) {
await initialTab.updateComplete;
this.#setFocusable(initialTab);
}
}

render() {
return html`
<div id="main">
@@ -278,6 +393,7 @@ export class UUITabGroupElement extends LitElement {
style="display: none"
id="more-button"
label="More"
tabindex="-1"
compact>
<uui-symbol-more></uui-symbol-more>
</uui-button>
@@ -286,7 +402,7 @@ export class UUITabGroupElement extends LitElement {
id="popover-container"
popover
placement="bottom-end">
<div id="hidden-tabs-container" role="tablist">
<div id="hidden-tabs-container" tabindex="-1">
${repeat(this.#hiddenTabElements, el => html`${el}`)}
</div>
</uui-popover-container>
@@ -305,6 +421,7 @@ export class UUITabGroupElement extends LitElement {
display: flex;
justify-content: space-between;
overflow: hidden;
outline: none;
}

#grid {
86 changes: 84 additions & 2 deletions packages/uui-tabs/lib/uui-tab.element.ts
Original file line number Diff line number Diff line change
@@ -64,32 +64,113 @@ 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();
e.stopImmediatePropagation();
}
}

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`
<a
id="button"
tabindex="-1"
role="tab"
href=${ifDefined(!this.disabled ? this.href : undefined)}
target=${ifDefined(this.target || undefined)}
rel=${ifDefined(
this.rel ||
ifDefined(
this.target === '_blank' ? 'noopener noreferrer' : undefined,
),
)}
role="tab">
)}>
<slot name="icon"></slot>
${this.renderLabel()}
<slot name="extra"></slot>
@@ -100,6 +181,7 @@ export class UUITabElement extends ActiveMixin(LabelMixin('', LitElement)) {
type="button"
id="button"
?disabled=${this.disabled}
tabindex="-1"
role="tab">
<slot name="icon"></slot>
${this.renderLabel()}
187 changes: 183 additions & 4 deletions packages/uui-tabs/lib/uui-tabs.test.ts
Original file line number Diff line number Diff line change
@@ -14,9 +14,9 @@ describe('UuiTab', () => {
beforeEach(async () => {
element = await fixture(html`
<uui-tab-group>
<uui-tab label="Content">Content</uui-tab>
<uui-tab label="Packages">Packages</uui-tab>
<uui-tab label="Media" active>Media</uui-tab>
<uui-tab label="Content" href="#content">Content</uui-tab>
<uui-tab label="Packages" href="#packages">Packages</uui-tab>
<uui-tab label="Media" active href="#media">Media</uui-tab>
<uui-tab label="Content1">Content to force a more button</uui-tab>
<uui-tab label="Content2">Content to force a more button</uui-tab>
<uui-tab label="Content3">Content to force a more button</uui-tab>
@@ -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<void>(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<void>(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`
<uui-tab-group>
<uui-tab label="Content">Content</uui-tab>
<uui-tab label="Packages">Packages</uui-tab>
<uui-tab label="Media">Media</uui-tab>
</uui-tab-group>
`);

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
});
});

Unchanged files with check annotations Beta

type Constructor<T = {}> = new (...args: any[]) => T;
export declare class SelectableMixinInterface extends LitElement {

Check warning on line 8 in packages/uui-base/lib/mixins/SelectableMixin.ts

GitHub Actions / test

Class declaration should be prefixed with "UUI"
/**
* Enable the ability to select this element.
* @attr
* @fires {UUISelectableEvent} selected - fires when the media card is selected
* @fires {UUISelectableEvent} deselected - fires when the media card is deselected
*/
class SelectableMixinClass extends superClass {

Check warning on line 42 in packages/uui-base/lib/mixins/SelectableMixin.ts

GitHub Actions / test

Class declaration should be prefixed with "UUI"
private _selectable = false;
/**
* Enable the ability to select this element.
type Constructor<T = {}> = new (...args: any[]) => T;
export declare class SelectOnlyMixinInterface extends SelectableMixinInterface {

Check warning on line 6 in packages/uui-base/lib/mixins/SelectOnlyMixin.ts

GitHub Actions / test

Class declaration should be prefixed with "UUI"
selectOnly: boolean;
}
>(
superClass: T,
) => {
class SelectOnlyMixinClass extends superClass {

Check warning on line 22 in packages/uui-base/lib/mixins/SelectOnlyMixin.ts

GitHub Actions / test

Class declaration should be prefixed with "UUI"
private _selectOnly = false;
/**
type Constructor<T = {}> = new (...args: any[]) => T;
export declare class PopoverTargetMixinInterface {

Check warning on line 7 in packages/uui-base/lib/mixins/PopoverTargetMixin.ts

GitHub Actions / test

Class declaration should be prefixed with "UUI"
/**
* Set a popovertarget.
* @type {string}
/**
* Popover target mixin class containing the popover target functionality.
*/
class PopoverTargetMixinClass extends superClass {

Check warning on line 34 in packages/uui-base/lib/mixins/PopoverTargetMixin.ts

GitHub Actions / test

Class declaration should be prefixed with "UUI"
/**
* Set a popovertarget.
* @type {string}
type Constructor<T = {}> = new (...args: any[]) => T;
export declare class LabelMixinInterface {

Check warning on line 6 in packages/uui-base/lib/mixins/LabelMixin.ts

GitHub Actions / test

Class declaration should be prefixed with "UUI"
/**
* Label to be used for aria-label and potentially as visual label for some components
* @type {string}
/**
* Label mixin class containing the label functionality.
*/
class LabelMixinClass extends superClass {

Check warning on line 31 in packages/uui-base/lib/mixins/LabelMixin.ts

GitHub Actions / test

Class declaration should be prefixed with "UUI"
/**
* Label to be used for aria-label and potentially as visual label for some components
* @type {string}
type Constructor<T = {}> = new (...args: any[]) => T;
export declare class ActiveMixinInterface {

Check warning on line 6 in packages/uui-base/lib/mixins/ActiveMixin.ts

GitHub Actions / test

Class declaration should be prefixed with "UUI"
/**
* Set this boolean to true for then the related composition is sorted.
* @type {boolean}
export const ActiveMixin = <T extends Constructor<LitElement>>(
superClass: T,
) => {
class ActiveMixinClass extends superClass {

Check warning on line 25 in packages/uui-base/lib/mixins/ActiveMixin.ts

GitHub Actions / test

Class declaration should be prefixed with "UUI"
/**
* Set this boolean to true for then the related composition is sorted.
* @type {boolean}