Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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 @@
private _popoverContainerElement!: UUIPopoverContainerElement;

@query('#main') private _mainElement!: HTMLElement;
@query('#grid') private _gridElement!: HTMLElement;

Check warning on line 29 in packages/uui-tabs/lib/uui-tab-group.element.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member '_gridElement' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=umbraco_Umbraco.UI&issues=AZpuguP_kp7V0Wlt1AXE&open=AZpuguP_kp7V0Wlt1AXE&pullRequest=1112

@queryAssignedElements({
flatten: true,
selector: 'uui-tab, [uui-tab], [role=tab]',
})
private _slottedNodes?: HTMLElement[];
private _slottedNodes?: UUITabElement[];

Check warning on line 35 in packages/uui-tabs/lib/uui-tab-group.element.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member '_slottedNodes' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=umbraco_Umbraco.UI&issues=AZpuguP_kp7V0Wlt1AXF&open=AZpuguP_kp7V0Wlt1AXF&pullRequest=1112

/** Stores the current gap used in the breakpoints */
#currentGap = 0;
Expand All @@ -49,7 +50,7 @@
})
dropdownContentDirection: 'vertical' | 'horizontal' = 'vertical';

#tabElements: HTMLElement[] = [];
#tabElements: UUITabElement[] = [];

#hiddenTabElements: UUITabElement[] = [];
#hiddenTabElementsMap: Map<UUITabElement, UUITabElement> = new Map();
Expand All @@ -64,14 +65,74 @@
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) => {

Check warning on line 91 in packages/uui-tabs/lib/uui-tab-group.element.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member '#onKeyDown' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=umbraco_Umbraco.UI&issues=AZpuguP_kp7V0Wlt1AXH&open=AZpuguP_kp7V0Wlt1AXH&pullRequest=1112
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 @@
this.#visibilityBreakpoints.length = 0;
}

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

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

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

await this.#setInitialFocusable();
}

#onTabClicked = (e: MouseEvent) => {
Expand Down Expand Up @@ -163,7 +224,6 @@
});

// 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 @@ -192,7 +252,14 @@
this.#calculateBreakPoints();
}

#updateCollapsibleTabs(containerWidth: number) {

Check failure on line 255 in packages/uui-tabs/lib/uui-tab-group.element.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=umbraco_Umbraco.UI&issues=AZpuguP_kp7V0Wlt1AXI&open=AZpuguP_kp7V0Wlt1AXI&pullRequest=1112
this._gridElement.scrollLeft = 0;

// Reset translations for all tabs
this.#tabElements.forEach(tab => {
tab.style.transform = '';
});

const moreButtonWidth = this._moreButtonElement.offsetWidth;

const containerWithoutButtonWidth =
Expand All @@ -211,7 +278,7 @@
const len = this.#visibilityBreakpoints.length;
for (let i = 0; i < len; i++) {
const breakpoint = this.#visibilityBreakpoints[i];
const tab = this.#tabElements[i] as UUITabElement;

Check warning on line 281 in packages/uui-tabs/lib/uui-tab-group.element.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This assertion is unnecessary since it does not change the type of the expression.

See more on https://sonarcloud.io/project/issues?id=umbraco_Umbraco.UI&issues=AZpuguP_kp7V0Wlt1AXK&open=AZpuguP_kp7V0Wlt1AXK&pullRequest=1112

// If breakpoint is smaller than the container width, then show the tab.
// If last breakpoint, then we will use the containerWidth, as we do not want to include the more-button in that calculation.
Expand All @@ -235,13 +302,43 @@

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

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 @@
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 @@
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 @@
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 @@
@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 = () => {

Check warning on line 77 in packages/uui-tabs/lib/uui-tab.element.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member '#onFocus' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=umbraco_Umbraco.UI&issues=AZpuguK7kp7V0Wlt1AXA&open=AZpuguK7kp7V0Wlt1AXA&pullRequest=1112
this.#focus = true;
};

#onBlur = () => {

Check warning on line 81 in packages/uui-tabs/lib/uui-tab.element.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member '#onBlur' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=umbraco_Umbraco.UI&issues=AZpuguK7kp7V0Wlt1AXB&open=AZpuguK7kp7V0Wlt1AXB&pullRequest=1112
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,

Check warning on line 103 in packages/uui-tabs/lib/uui-tab.element.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=umbraco_Umbraco.UI&issues=AZpuguK7kp7V0Wlt1AXC&open=AZpuguK7kp7V0Wlt1AXC&pullRequest=1112
composed: true,
});

anchor.dispatchEvent(clickEvent);
}
} else {
this.dispatchEvent(
new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,

Check warning on line 114 in packages/uui-tabs/lib/uui-tab.element.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `globalThis` over `window`.

See more on https://sonarcloud.io/project/issues?id=umbraco_Umbraco.UI&issues=AZpuguK7kp7V0Wlt1AXD&open=AZpuguK7kp7V0Wlt1AXD&pullRequest=1112
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 @@
type="button"
id="button"
?disabled=${this.disabled}
tabindex="-1"
role="tab">
<slot name="icon"></slot>
${this.renderLabel()}
Expand Down
Loading
Loading