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
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
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
Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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');
Expand Down Expand Up @@ -103,9 +164,7 @@ export class UUITabGroupElement extends LitElement {
this.#visibilityBreakpoints.length = 0;
}

#onSlotChange() {
this.#cleanupTabs();

async #onSlotChange() {
this.#setTabArray();

this.#tabElements.forEach(el => {
Expand All @@ -116,6 +175,8 @@ export class UUITabGroupElement extends LitElement {
observer.observe(el);
this.#tabResizeObservers.push(observer);
});

await this.#setInitialFocusable();
}

#onTabClicked = (e: MouseEvent) => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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';
Expand All @@ -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">
Expand All @@ -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>
Expand All @@ -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>
Expand All @@ -305,6 +421,7 @@ export class UUITabGroupElement extends LitElement {
display: flex;
justify-content: space-between;
overflow: hidden;
outline: none;
}

#grid {
Expand Down
86 changes: 84 additions & 2 deletions packages/uui-tabs/lib/uui-tab.element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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()}
Expand Down
Loading
Loading