Skip to content
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
5 changes: 1 addition & 4 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
[core]
autocrlf=false
eol=lf
* text=auto
* text=auto eol=lf
9 changes: 9 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# Changelog


## [20.0.11] 📅 2026-08-24
### Fixes
- `@nova-ui/bits` | Fixed tab accessibility relationships by aligning `nui-tab` and consumer-owned tab panels with `tab-{id}` / `panel-{id}` IDs. `nui-tab-heading` now sets `aria-controls` only when the consumer provides the controlled panel ID through `ariaControls`.

## [20.0.10] 📅 2026-08-11
### Fixes
- `@nova-ui/bits` | SkipSpace a11y regression fixed

## [20.0.9] 📅 2026-08-05
### Fixes
- Essentional A11y fixies
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@
"trigger-pipeline-build-ci": "bash scripts/trigger-pipeline-build",
"verify-ci": "bash scripts/verify-published"
},
"version": "20.0.10",
"version": "20.0.11",
"workspaces": [
"packages/*"
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,25 @@
<nui-tab-heading
*ngFor="let tab of tabs"
[tabId]="tab.id"
[ariaControls]="'panel-' + tab.id"
[active]="currentTabId === tab.id"
>{{ tab.title }}</nui-tab-heading
>
</nui-tab-heading-group>
<div [style.display]="getTabDisplayMode(tabs[0].id)">
<div
role="tabpanel"
id="panel-tab1"
aria-labelledby="tab-tab1"
[style.display]="getTabDisplayMode(tabs[0].id)"
>
No content in this tab. See other tab(s) for content.
</div>
<div [style.display]="getTabDisplayMode(tabs[1].id)">
<div
role="tabpanel"
id="panel-tab2"
aria-labelledby="tab-tab2"
[style.display]="getTabDisplayMode(tabs[1].id)"
>
<repeat-virtual-scroll id="repeat-test-vscroll"></repeat-virtual-scroll>
</div>
<hr />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
<nui-tab-heading-group (selected)="updateContent($event)">
<nui-tab-heading
*ngFor="let tab of tabsetContent"
[tabId]="tab.id"
[tabId]="'dynamic-' + tab.id"
[ariaControls]="'panel-dynamic-' + tab.id"
[active]="currentTabId === tab.id"
>
<div class="d-flex align-content-center">
Expand All @@ -15,7 +16,13 @@
</nui-tab-heading>
</nui-tab-heading-group>

<div *ngFor="let tab of tabsetContent">
<div
*ngFor="let tab of tabsetContent"
role="tabpanel"
[id]="'panel-dynamic-' + tab.id"
[attr.aria-labelledby]="'tab-dynamic-' + tab.id"
[hidden]="currentTabId !== tab.id"
>
<nui-content size="small" *ngIf="currentTabId === tab.id">
<p>
Tab titeled <strong>{{ tab.title }}</strong> with id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<nui-tab-heading
*ngFor="let tab of tabsetContent"
[disabled]="tab.isDisabled"
[tabId]="tab.id"
[tabId]="'horizontal-with-icons-only-' + tab.id"
>
<div class="d-flex align-content-center">
<div class="d-inline-flex align-items-center">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<nui-tab-heading
*ngFor="let tab of tabsetContent"
[disabled]="tab.isDisabled"
[tabId]="tab.id"
[tabId]="'horizontal-with-icons-' + tab.id"
>
<div class="d-flex align-content-center">
<div class="d-inline-flex align-items-center">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<nui-tab-heading
*ngFor="let tab of tabsetContent"
[disabled]="tab.isDisabled"
[tabId]="tab.id"
[tabId]="'horizontal-' + tab.id"
>
<div class="d-flex align-content-center">
<div class="ellipsis">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<nui-tab-heading
*ngFor="let tab of tabsetContent"
[disabled]="tab.isDisabled"
[tabId]="tab.id"
[tabId]="'responsive-' + tab.id"
>
<div class="d-flex align-content-center">
<div class="d-inline-flex align-items-center">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
<nui-tab-heading
*ngFor="let tab of tabsetContent"
[disabled]="tab.isDisabled"
[tabId]="tab.id"
[tabId]="'vertical-with-icons-' + tab.id"
>
<div class="d-flex align-content-center">
<div class="d-inline-flex align-items-center">
<!-- That ternary operator below (bind to [iconColor]) applies different colors depending on
whether the tab is disabled, active, or inactive. Note that the icon color must follow
<!-- That ternary operator below (bind to [iconColor]) applies different colors depending on
whether the tab is disabled, active, or inactive. Note that the icon color must follow
the tab title color, according to UX requirements -->
<nui-icon
class="pr-2"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<nui-tab-heading
*ngFor="let tab of tabsetContent"
[disabled]="tab.isDisabled"
[tabId]="tab.id"
[tabId]="'vertical-' + tab.id"
>
<div class="d-flex align-content-center">
<span>{{ tab.title }}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
<nui-tab-heading
*ngFor="let tab of tabsetContent"
[disabled]="tab.isDisabled"
[tabId]="tab.id"
[tabId]="'with-content-' + tab.id"
[ariaControls]="'panel-with-content-' + tab.id"
>
<div class="d-flex align-content-center">
<div class="d-inline-flex align-items-center">
Expand All @@ -27,7 +28,13 @@
</nui-tab-heading>
</nui-tab-heading-group>

<div *ngFor="let tab of tabsetContent">
<div
*ngFor="let tab of tabsetContent"
role="tabpanel"
[id]="'panel-with-content-' + tab.id"
[attr.aria-labelledby]="'tab-with-content-' + tab.id"
[hidden]="currentTabId !== tab.id"
>
<nui-content size="small" *ngIf="currentTabId === tab.id">
<p>
Tab titeled <strong>{{ tab.title }}</strong> with id
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<nui-tab-heading
*ngFor="let tab of tabsetContent"
[disabled]="tab.isDisabled"
[tabId]="tab.id"
[tabId]="'with-icons-' + tab.id"
>
<div class="d-flex align-content-center">
<div class="d-inline-flex align-items-center">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
<div>
<nui-tab-heading-group>
<!-- This example renders one shared routed panel. Only the active tab points
to the panel currently rendered by the router outlet. -->
<nui-tab-heading
*ngFor="let tab of tabsetContent"
[tabId]="tab.id"
[ariaControls]="
currentTabRoute === tab.id ? 'panel-' + tab.id : null
"
tabindex="-1"
routerLink="/tabgroup/{{ tab.id }}"
[active]="currentTabRoute === tab.id"
>
Expand All @@ -28,6 +34,14 @@
</nui-tab-heading>
</nui-tab-heading-group>

<!-- Tab content is placed below depending on the component used in a certain routing -->
<router-outlet></router-outlet>
<div
role="tabpanel"
[attr.id]="currentTabRoute ? 'panel-' + currentTabRoute : null"
[attr.aria-labelledby]="
currentTabRoute ? 'tab-' + currentTabRoute : null
"
>
<!-- Tab content is placed below depending on the component used in a certain routing -->
<router-outlet></router-outlet>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,99 @@ test.describe("USERCONTROL tab heading group", () => {
await lastTab.click();
await lastTab.toBeActive();
});

test("should activate a focused tab with Enter and Space", async ({
page,
}) => {
await Helpers.prepareBrowser("tabgroup", page);

const tabs = page.locator(
"nui-tab-heading-group-dynamic-example [role='tab']"
);

await tabs.nth(1).focus();
await page.keyboard.press("Enter");
await expect(tabs.nth(1)).toHaveAttribute("aria-selected", "true");

await tabs.nth(0).focus();
await page.keyboard.press("Space");
await expect(tabs.nth(0)).toHaveAttribute("aria-selected", "true");
});

test("should relate each routed tab to the current panel", async ({
page,
}) => {
const routes = ["tab-settings", "tab-statistics", "tab-about"];

for (const route of routes) {
await Helpers.prepareBrowser(`tabgroup/${route}`, page);

const routerExample = page.locator(
"nui-tab-heading-group-with-router-example"
);
const activeTab = routerExample.locator(
"[role='tab'][aria-selected='true']"
);
const panel = routerExample.locator("[role='tabpanel']");

await expect(activeTab).toHaveAttribute("id", `tab-${route}`);
await expect(activeTab).toHaveAttribute(
"aria-controls",
`panel-${route}`
);
await expect(panel).toHaveCount(1);
await expect(panel).toHaveAttribute("id", `panel-${route}`);
await expect(panel).toHaveAttribute(
"aria-labelledby",
`tab-${route}`
);
await expect(
routerExample.locator(
"[role='tab'][aria-selected='false'][aria-controls]"
)
).toHaveCount(0);
}
});

test("should update the routed panel relationship when switching tabs", async ({
page,
}) => {
await Helpers.prepareBrowser("tabgroup/tab-settings", page);

const routerExample = page.locator(
"nui-tab-heading-group-with-router-example"
);
const statisticsTab = routerExample.locator("[role='tab']").nth(1);
await statisticsTab.focus();
await page.keyboard.press("Enter");

await expect(page).toHaveURL(/\/#\/tabgroup\/tab-statistics$/);

const activeTab = routerExample.locator(
"[role='tab'][aria-selected='true']"
);
const panel = routerExample.locator("[role='tabpanel']");

await expect(activeTab).toHaveAttribute("id", "tab-tab-statistics");
await expect(activeTab).toHaveAttribute(
"aria-controls",
"panel-tab-statistics"
);
await expect(panel).toHaveAttribute("id", "panel-tab-statistics");
await expect(panel).toHaveAttribute(
"aria-labelledby",
"tab-tab-statistics"
);
await expect(
routerExample.locator(
"[role='tab'][aria-selected='false'][aria-controls]"
)
).toHaveCount(0);
await expect(
routerExample.locator("nui-tab-heading[tabindex='-1']")
).toHaveCount(3);
await expect(
routerExample.locator("[role='tab'][tabindex='0']")
).toHaveCount(3);
});
});
2 changes: 1 addition & 1 deletion packages/bits/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,6 @@
"visual:watch": "npx watch \"yarn run visual:base\" src demo spec --watch=1"
},
"typings": "public_api.d.ts",
"version": "20.0.10",
"version": "20.0.11",
"packageManager": "yarn@1.22.18"
}
2 changes: 1 addition & 1 deletion packages/bits/schematics/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "nova-schematics",
"license": "Apache-2.0",
"version": "20.0.10",
"version": "20.0.11",
"scripts": {
"assemble": "run-s build copy:json copy:data test copy:dist",
"build": "tsc -p tsconfig.json",
Expand Down
22 changes: 22 additions & 0 deletions packages/bits/src/constants/tabgroup.constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// © 2026 SolarWinds Worldwide, LLC. All rights reserved.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to
// deal in the Software without restriction, including without limitation the
// rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
// sell copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

export const TAB_ID_PREFIX = "tab-";
export const TAB_PANEL_ID_PREFIX = "panel-";
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
<span
class="tab-link"
role="tab"
[id]="tab.tabId"
[id]="tab.tabControlId"
[attr.aria-controls]="tab.panelId"
[attr.tabindex]="tab.disabled ? -1 : 0"
[attr.aria-selected]="tab.active"
Expand Down
Loading