diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 1d7737c6e2..59502d90d7 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -1,5 +1,20 @@
# Changelog
+## [21.0.5] 📅 2026-10-01
+
+### Added
+
+- `@nova-ui/bits` | **nui-overlay** new inputs `trapFocus`, `idAttr`, `ariaDescribedby` and `ariaModal`
+- `@nova-ui/bits` | **nui-dialog** new inputs `ariaLabel`, `ariaLabelledby` and `ariaDescribedby`
+- `@nova-ui/bits` | **nui-expander** new input `useRegionLandmark`; the body is no longer a `region` landmark by default
+
+### Fixes
+
+- `@nova-ui/bits` | SkipSpace a11y regression fixed
+- `@nova-ui/bits` | A11y fixes for nui-progress, nui-dialog, nui-textbox-number, nui-tab-group, nui-message, nui-toast, nui-range-filter, nui-date-picker and nui-menu
+- `@nova-ui/bits` | **nui-dialog** is labelled by its **nui-dialog-header** title automatically
+- `@nova-ui/dashboards` | Keyboard access for KPI tiles, risk score tiles and table widget rows
+
## [21.0.4] 📅 2026-09-16
### Added
@@ -9,11 +24,8 @@
### Fixes
-- `@nova-ui/bits` | A11y fixies for Home Summary
-- `@nova-ui/bits` | A11y fixies for Search component
- `@nova-ui/bits` | new accessible blue for active menu button text
- Essentional A11y fixies
-- `@nova-ui/bits` | Added unique IDs to expander landmarks
- `@nova-ui/bits` | A11y fixes for nui-toolbar
- Prettier fix
diff --git a/docs/E2E/VISUAL.md b/docs/E2E/VISUAL.md
index a6d72f3ce8..2c888b1701 100644
--- a/docs/E2E/VISUAL.md
+++ b/docs/E2E/VISUAL.md
@@ -15,8 +15,8 @@ Goals:
## Setup
- Playwright config at `packages/bits/playwright.config.ts` defines a `visual` project and outputs artifacts to `test-results/`.
-- Manual snapshots: set `SNAPSHOTS_UPLOAD=manual` to write PNGs to `_snapshots/`.
-- Percy (optional): set `PERCY_TOKEN` in your environment and run via `percy exec`.
+- Without `PERCY_TOKEN` (default, also in CI): the Eyes lens saves full-page Playwright screenshots as PNGs to `_snapshots/`.
+- With `PERCY_TOKEN`: the Percy lens sends DOM snapshots via `@percy/playwright` (run via `percy exec`); `PERCY_DEFAULT_CONFIG` (widths, `percyCSS`) applies only here.
## Virtual Camera API
@@ -27,12 +27,21 @@ Import from `packages/bits/e2e/virtual-camera`:
- `camera.say.cheese(label)` – capture a snapshot with an optional stabilization delay
- `camera.be.responsive(widths, callback?)` – set responsive widths and optional callback that receives the Playwright `page`
-By default, the Camera engine selects the Percy lens when `process.env.PERCY` is present. Otherwise, with `SNAPSHOTS_UPLOAD=manual`, it saves PNGs.
+The Camera engine selects the Percy lens only when `process.env.PERCY_TOKEN` is set; otherwise it uses the Eyes lens (PNG screenshots).
+
+### How CI produces Percy snapshots
+
+CircleCI runs the UI tests **without** `PERCY_TOKEN`, so every snapshot is a Playwright screenshot written to `_snapshots/` by the Eyes lens. A later step uploads these images with `percy upload _snapshots`. Consequences:
+
+- Percy does not re-render the page; `percyCSS` and other Percy lens options have no effect in CI.
+- Snapshot stability must be ensured before/while the screenshot is taken:
+ - `camera.say.cheese()` loads font faces used by visible text, then waits for font and layout work to settle before taking the screenshot.
+ - The Eyes lens takes screenshots with `animations: "disabled"` and `caret: "hide"`, so infinite CSS animations (spinners, progress bars) are frozen.
+- To reproduce CI snapshots locally, run the visual tests without `PERCY_TOKEN` and inspect the PNGs in `_snapshots/`.
## Best Practices
-- Disable CSS animations for visual runs:
- - `await Helpers.disableCSSAnimations(Animations.TRANSITIONS_AND_ANIMATIONS)`
+- Animations are frozen by the camera at screenshot time. `Helpers.disableCSSAnimations(...)` injects its styles via `page.addInitScript`, so it only affects pages loaded after the call (not the page already opened by `Helpers.prepareBrowser`).
- Interact via Atoms (see `docs/E2E/ATOMS.md`) to keep tests resilient.
- Use meaningful snapshot labels tied to user actions.
- Prefer consistent viewport widths; set `camera.be.responsive([1920])` as needed.
diff --git a/packages/bits/demo/src/components/demo/demo.files.ts b/packages/bits/demo/src/components/demo/demo.files.ts
index fc74adf6bb..b509bd1acc 100644
--- a/packages/bits/demo/src/components/demo/demo.files.ts
+++ b/packages/bits/demo/src/components/demo/demo.files.ts
@@ -473,6 +473,8 @@ export const DEMO_PATHS = [
"dialog/component-as-content/dialog-content.example.component.ts",
"dialog/confirmation-dialog/confirmation-dialog.example.component.html",
"dialog/confirmation-dialog/confirmation-dialog.example.component.ts",
+ "dialog/dialog-accessibility/dialog-accessibility.example.component.html",
+ "dialog/dialog-accessibility/dialog-accessibility.example.component.ts",
"dialog/dialog-actions-before-closure/dialog-actions-before-closure.example.component.html",
"dialog/dialog-actions-before-closure/dialog-actions-before-closure.example.component.ts",
"dialog/dialog-after-opened/dialog-after-opened.example.component.html",
@@ -774,6 +776,8 @@ export const DEMO_PATHS = [
"overlay/overlay-custom-styles/overlay-custom-styles.example.component.ts",
"overlay/overlay-docs/overlay-docs.example.component.html",
"overlay/overlay-docs/overlay-docs.example.component.ts",
+ "overlay/overlay-focus-trap/overlay-focus-trap.example.component.html",
+ "overlay/overlay-focus-trap/overlay-focus-trap.example.component.ts",
"overlay/overlay-popup-styles/overlay-popup-styles.example.component.html",
"overlay/overlay-popup-styles/overlay-popup-styles.example.component.ts",
"overlay/overlay-show-hide-toggle/overlay-show-hide-toggle.example.component.html",
diff --git a/packages/bits/demo/src/components/demo/dialog/dialog-accessibility/dialog-accessibility.example.component.html b/packages/bits/demo/src/components/demo/dialog/dialog-accessibility/dialog-accessibility.example.component.html
new file mode 100644
index 0000000000..c2eefadde4
--- /dev/null
+++ b/packages/bits/demo/src/components/demo/dialog/dialog-accessibility/dialog-accessibility.example.component.html
@@ -0,0 +1,26 @@
+
+
+
+
+ The report will be permanently deleted. This action cannot be
+ undone.
+
+
+
+
+ Cancel
+
+
+ Delete
+
+
+
+
+ Delete report
diff --git a/packages/bits/demo/src/components/demo/dialog/dialog-accessibility/dialog-accessibility.example.component.ts b/packages/bits/demo/src/components/demo/dialog/dialog-accessibility/dialog-accessibility.example.component.ts
new file mode 100644
index 0000000000..c7155b318a
--- /dev/null
+++ b/packages/bits/demo/src/components/demo/dialog/dialog-accessibility/dialog-accessibility.example.component.ts
@@ -0,0 +1,40 @@
+// © 2022 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.
+
+import { Component, inject, TemplateRef, ViewChild } from "@angular/core";
+
+import { DialogService } from "@nova-ui/bits";
+
+@Component({
+ selector: "nui-dialog-accessibility-example",
+ templateUrl: "./dialog-accessibility.example.component.html",
+ standalone: false,
+})
+export class DialogAccessibilityExampleComponent {
+ @ViewChild("dialogTemplate") public dialogTemplate: TemplateRef;
+
+ private readonly dialogService = inject(DialogService);
+
+ public open(): void {
+ this.dialogService.open(this.dialogTemplate, {
+ ariaDescribedby: "nui-dialog-a11y-description",
+ });
+ }
+}
diff --git a/packages/bits/demo/src/components/demo/dialog/dialog-docs/dialog-docs.example.component.html b/packages/bits/demo/src/components/demo/dialog/dialog-docs/dialog-docs.example.component.html
index e9f5e190fd..1a9479c890 100644
--- a/packages/bits/demo/src/components/demo/dialog/dialog-docs/dialog-docs.example.component.html
+++ b/packages/bits/demo/src/components/demo/dialog/dialog-docs/dialog-docs.example.component.html
@@ -182,6 +182,23 @@ Keyboard Options
+Accessibility
+
+ Every dialog needs an accessible name. When the dialog contains
+ nui-dialog-header, it is labelled by the header title
+ automatically. If you use a custom title element, pass its id as
+ ariaLabelledby; if there is no visible title, pass
+ ariaLabel in the options of
+ DialogService.open. Use ariaDescribedby to point
+ to the element with the main message, so screen readers announce it when the
+ dialog opens.
+
+
+
+
+
Static Backdrop
By default, nui-dialog closes if user clicks outside the dialog. It is handy
diff --git a/packages/bits/demo/src/components/demo/dialog/dialog.module.ts b/packages/bits/demo/src/components/demo/dialog/dialog.module.ts
index 234e19d816..484ef98a94 100644
--- a/packages/bits/demo/src/components/demo/dialog/dialog.module.ts
+++ b/packages/bits/demo/src/components/demo/dialog/dialog.module.ts
@@ -47,6 +47,7 @@ import {
ConfirmationDialogExampleComponent,
DialogActionBeforeClosureExampleComponent,
DialogAfterOpenedExampleComponent,
+ DialogAccessibilityExampleComponent,
DialogContentExampleComponent,
DialogCustomClassExampleComponent,
DialogDocsComponent,
@@ -105,6 +106,15 @@ const routes = [
},
},
},
+ {
+ path: "accessibility",
+ component: DialogAccessibilityExampleComponent,
+ data: {
+ srlc: {
+ hideIndicator: true,
+ },
+ },
+ },
{
path: "dialog-visual-test",
component: DialogVisualTestComponent,
@@ -145,6 +155,7 @@ const routes = [
declarations: [
DialogContentExampleComponent,
ComponentAsContentExampleComponent,
+ DialogAccessibilityExampleComponent,
ConfirmationDialogExampleComponent,
DialogCustomClassExampleComponent,
DialogDocsComponent,
diff --git a/packages/bits/demo/src/components/demo/dialog/index.ts b/packages/bits/demo/src/components/demo/dialog/index.ts
index a444ebf641..e49cf0fc1a 100644
--- a/packages/bits/demo/src/components/demo/dialog/index.ts
+++ b/packages/bits/demo/src/components/demo/dialog/index.ts
@@ -35,6 +35,7 @@ export * from "./dialog-inside-overlay/dialog-inside-overlay.example.component";
export * from "./dialog-actions-before-closure/dialog-actions-before-closure.example.component";
export * from "./dialog-zindex-test/dialog-zindex-test.component";
export * from "./dialog-after-opened/dialog-after-opened.example.component";
+export * from "./dialog-accessibility/dialog-accessibility.example.component";
export * from "./dialog-actions-before-closure/dialog-actions-before-closure.example.component";
export * from "./dialog-test/dialog-test.example.component";
export * from "./dialog-inside-overlay-with-date-time-picker/dialog-inside-overlay-with-date-time-picker.example.component";
diff --git a/packages/bits/demo/src/components/demo/overlay/index.ts b/packages/bits/demo/src/components/demo/overlay/index.ts
index a50ecbc2ec..0364549258 100644
--- a/packages/bits/demo/src/components/demo/overlay/index.ts
+++ b/packages/bits/demo/src/components/demo/overlay/index.ts
@@ -27,5 +27,6 @@ export * from "./overlay-test/overlay-test.example.component";
export * from "./overlay-viewport-margin/overlay-viewport-margin-example.component";
export * from "./overlay-arrow/overlay-arrow.example.component";
export * from "./overlay-popup-styles/overlay-popup-styles.example.component";
+export * from "./overlay-focus-trap/overlay-focus-trap.example.component";
export * from "./overlay-custom-dialog/overlay-custom-dialog.component";
export * from "./overlay-custom-confirmation-inside-dialog/overlay-custom-confirmation-inside-dialog.component";
diff --git a/packages/bits/demo/src/components/demo/overlay/overlay-custom-confirmation-inside-dialog/overlay-custom-confirmation-inside-dialog.component.html b/packages/bits/demo/src/components/demo/overlay/overlay-custom-confirmation-inside-dialog/overlay-custom-confirmation-inside-dialog.component.html
index 37f4a6463e..06f71ec457 100644
--- a/packages/bits/demo/src/components/demo/overlay/overlay-custom-confirmation-inside-dialog/overlay-custom-confirmation-inside-dialog.component.html
+++ b/packages/bits/demo/src/components/demo/overlay/overlay-custom-confirmation-inside-dialog/overlay-custom-confirmation-inside-dialog.component.html
@@ -48,21 +48,23 @@
-
+
-
+
}
diff --git a/packages/bits/src/lib/dialog/confirmation-dialog.component.ts b/packages/bits/src/lib/dialog/confirmation-dialog.component.ts
index ede99e0141..24b9b22674 100644
--- a/packages/bits/src/lib/dialog/confirmation-dialog.component.ts
+++ b/packages/bits/src/lib/dialog/confirmation-dialog.component.ts
@@ -30,7 +30,7 @@ import { ConfirmationDialogButtons, SeverityLevels } from "./public-api";
selector: "nui-confirmation-dialog-window",
templateUrl: "./confirmation-dialog.component.html",
host: {
- role: "dialog",
+ "role": "dialog",
"aria-modal": "true",
"[attr.aria-label]": "getAriaLabel()",
},
diff --git a/packages/bits/src/lib/dialog/dialog-header.component.html b/packages/bits/src/lib/dialog/dialog-header.component.html
index fccd525f71..142dfee165 100644
--- a/packages/bits/src/lib/dialog/dialog-header.component.html
+++ b/packages/bits/src/lib/dialog/dialog-header.component.html
@@ -3,7 +3,7 @@
@if (severityIcon) {
}
-
{{ title }}
+ {{ title }}
@@ -17,7 +17,6 @@
{{ title }}
icon="close"
ariaLabel="Close"
i18n-ariaLabel
- (click)="innerClose($event)"
- >
+ (click)="innerClose($event)">
diff --git a/packages/bits/src/lib/dialog/dialog-header.component.spec.ts b/packages/bits/src/lib/dialog/dialog-header.component.spec.ts
index 3047f2248e..035049443b 100644
--- a/packages/bits/src/lib/dialog/dialog-header.component.spec.ts
+++ b/packages/bits/src/lib/dialog/dialog-header.component.spec.ts
@@ -57,5 +57,24 @@ describe("components >", () => {
expect(subject.closed.emit).toHaveBeenCalledWith(event);
});
+
+ it("should give each title a unique id", () => {
+ fixture.detectChanges();
+ const secondFixture = TestBed.createComponent(
+ DialogHeaderComponent
+ );
+ secondFixture.detectChanges();
+
+ const firstTitle =
+ fixture.nativeElement.querySelector(".dialog-title");
+ const secondTitle =
+ secondFixture.nativeElement.querySelector(".dialog-title");
+
+ expect(firstTitle.id).not.toBe("");
+ expect(secondTitle.id).not.toBe("");
+ expect(firstTitle.id).not.toBe(secondTitle.id);
+
+ secondFixture.destroy();
+ });
});
});
diff --git a/packages/bits/src/lib/dialog/dialog-header.component.ts b/packages/bits/src/lib/dialog/dialog-header.component.ts
index 5c27b48030..176959f6d3 100644
--- a/packages/bits/src/lib/dialog/dialog-header.component.ts
+++ b/packages/bits/src/lib/dialog/dialog-header.component.ts
@@ -21,6 +21,7 @@
import { Component, EventEmitter, Input, OnInit, Output } from "@angular/core";
import { SeverityLevels } from "./public-api";
+import { _uniqueId } from "../../functions/unique-id";
/**
* @ignore
@@ -47,6 +48,7 @@ export class DialogHeaderComponent implements OnInit {
public severityClass = "";
public severityIcon = "";
+ public readonly titleId = _uniqueId("nui-dialog-title-");
public ngOnInit(): void {
if (this.severity) {
diff --git a/packages/bits/src/lib/dialog/dialog-service.spec.ts b/packages/bits/src/lib/dialog/dialog-service.spec.ts
index ba132007f8..d189583a91 100644
--- a/packages/bits/src/lib/dialog/dialog-service.spec.ts
+++ b/packages/bits/src/lib/dialog/dialog-service.spec.ts
@@ -91,6 +91,14 @@ export class WithActiveDialogComponent {
}
}
+@Component({
+ selector: "nui-dialog-header-content-cmpt",
+ template:
+ " ",
+ imports: [NuiDialogModule],
+})
+class WithDialogHeaderComponent {}
+
@Component({
selector: "nui-test-cmpt",
template: `
@@ -116,6 +124,9 @@ export class WithActiveDialogComponent {
}
+
+
+
Open
@@ -135,6 +146,8 @@ class TestComponent {
@ViewChild("contentWithClose", { static: true }) tplContentWithClose: any;
@ViewChild("contentWithDismiss", { static: true })
tplContentWithDismiss: any;
+ @ViewChild("contentWithDialogHeader", { static: true })
+ tplContentWithDialogHeader: any;
@ViewChild("contentWithIf", { static: true }) tplContentWithIf: any;
constructor(public dialogService: DialogService) {}
@@ -167,6 +180,12 @@ class TestComponent {
openTplDismiss(options?: object) {
return this.dialogService.open(this.tplContentWithDismiss, options);
}
+ openTplWithDialogHeader(options?: object) {
+ return this.dialogService.open(
+ this.tplContentWithDialogHeader,
+ options
+ );
+ }
openTplIf(options?: object) {
return this.dialogService.open(this.tplContentWithIf, options);
}
@@ -180,7 +199,7 @@ class TestComponent {
WithActiveDialogComponent,
],
exports: [TestComponent, DestroyableComponent],
- imports: [CommonModule, NuiDialogModule],
+ imports: [CommonModule, NuiDialogModule, WithDialogHeaderComponent],
providers: [SpyService],
})
class DialogTestModule {}
@@ -317,6 +336,125 @@ describe("nui-dialog", () => {
expect(fixture.nativeElement).not.toHaveDialog();
});
+ it("should label dialog by the title in template content", async () => {
+ const dialogRef =
+ fixture.componentInstance.openTplWithDialogHeader();
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ const dialogs = document.querySelectorAll("nui-dialog-window");
+ const dialog = dialogs[dialogs.length - 1] as HTMLElement;
+ const accessibleName = document
+ .getElementById(dialog.getAttribute("aria-labelledby")!)
+ ?.textContent?.trim();
+
+ expect(accessibleName).toBe("Template report");
+
+ dialogRef.close();
+ fixture.detectChanges();
+ });
+
+ it("should label dialog by the title in component content", async () => {
+ const dialogRef = fixture.componentInstance.openCmpt(
+ WithDialogHeaderComponent
+ );
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ const dialogs = document.querySelectorAll("nui-dialog-window");
+ const dialog = dialogs[dialogs.length - 1] as HTMLElement;
+ const accessibleName = document
+ .getElementById(dialog.getAttribute("aria-labelledby")!)
+ ?.textContent?.trim();
+
+ expect(accessibleName).toBe("Component report");
+
+ dialogRef.close();
+ fixture.detectChanges();
+ });
+
+ it("should label each stacked dialog by its own title", async () => {
+ const templateDialogRef =
+ fixture.componentInstance.openTplWithDialogHeader();
+ const componentDialogRef = fixture.componentInstance.openCmpt(
+ WithDialogHeaderComponent
+ );
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ const dialogs = Array.from(
+ document.querySelectorAll("nui-dialog-window")
+ );
+ const expectedTitles = ["Template report", "Component report"];
+
+ expect(dialogs.length).toBe(2);
+ dialogs.forEach((dialog, index) => {
+ const labelledby = dialog.getAttribute("aria-labelledby");
+ const title = document.getElementById(labelledby ?? "");
+
+ expect(title).not.toBeNull();
+ expect(dialog.contains(title)).toBeTrue();
+ expect(title?.textContent?.trim()).toBe(expectedTitles[index]);
+ });
+
+ componentDialogRef.close();
+ templateDialogRef.close();
+ fixture.detectChanges();
+ });
+
+ it("should not reference a missing title when the dialog has no header", async () => {
+ const dialogRef = fixture.componentInstance.openTpl();
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ const dialogs = document.querySelectorAll("nui-dialog-window");
+ const dialog = dialogs[dialogs.length - 1] as HTMLElement;
+
+ expect(dialog.hasAttribute("aria-labelledby")).toBeFalse();
+
+ dialogRef.close();
+ fixture.detectChanges();
+ });
+
+ it("should not set an automatic label when ariaLabel is provided", async () => {
+ const dialogRef = fixture.componentInstance.openTplWithDialogHeader(
+ {
+ ariaLabel: "Report dialog",
+ }
+ );
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ const dialogs = document.querySelectorAll("nui-dialog-window");
+ const dialog = dialogs[dialogs.length - 1] as HTMLElement;
+
+ expect(dialog.getAttribute("aria-label")).toBe("Report dialog");
+ expect(dialog.hasAttribute("aria-labelledby")).toBeFalse();
+
+ dialogRef.close();
+ fixture.detectChanges();
+ });
+
+ it("should keep an explicit ariaLabelledby value", async () => {
+ const dialogRef = fixture.componentInstance.openTplWithDialogHeader(
+ {
+ ariaLabelledby: "custom-dialog-title",
+ }
+ );
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ const dialogs = document.querySelectorAll("nui-dialog-window");
+ const dialog = dialogs[dialogs.length - 1] as HTMLElement;
+
+ expect(dialog.getAttribute("aria-labelledby")).toBe(
+ "custom-dialog-title"
+ );
+
+ dialogRef.close();
+ fixture.detectChanges();
+ });
+
it("should properly destroy TemplateRef content", () => {
const spyService = fixture.debugElement.injector.get(SpyService);
const dialogInstance =
diff --git a/packages/bits/src/lib/dialog/dialog-stack.service.ts b/packages/bits/src/lib/dialog/dialog-stack.service.ts
index 0aca66c570..c223e589e3 100644
--- a/packages/bits/src/lib/dialog/dialog-stack.service.ts
+++ b/packages/bits/src/lib/dialog/dialog-stack.service.ts
@@ -43,7 +43,15 @@ import { OverlayContainerService } from "../overlay/public-api";
*/
@Injectable({ providedIn: "root" })
export class DialogStackService {
- private windowAttributes = ["backdrop", "keyboard", "size", "windowClass"];
+ private windowAttributes = [
+ "backdrop",
+ "keyboard",
+ "size",
+ "windowClass",
+ "ariaLabel",
+ "ariaLabelledby",
+ "ariaDescribedby",
+ ];
constructor(
private applicationRef: ApplicationRef,
diff --git a/packages/bits/src/lib/dialog/dialog.component.spec.ts b/packages/bits/src/lib/dialog/dialog.component.spec.ts
index 3097b05c89..45e358762a 100644
--- a/packages/bits/src/lib/dialog/dialog.component.spec.ts
+++ b/packages/bits/src/lib/dialog/dialog.component.spec.ts
@@ -72,11 +72,23 @@ describe("components >", () => {
});
it("aria attributes", () => {
+ fixture.componentInstance.ariaLabel = "Dialog Label";
+ fixture.componentInstance.ariaLabelledby = "title-id";
+ fixture.componentInstance.ariaDescribedby = "desc-id";
fixture.detectChanges();
expect(fixture.nativeElement.getAttribute("role")).toBe(
"dialog"
);
+ expect(fixture.nativeElement.getAttribute("aria-label")).toBe(
+ "Dialog Label"
+ );
+ expect(
+ fixture.nativeElement.getAttribute("aria-labelledby")
+ ).toBe("title-id");
+ expect(
+ fixture.nativeElement.getAttribute("aria-describedby")
+ ).toBe("desc-id");
});
it("should contain focus trap attribute", () => {
diff --git a/packages/bits/src/lib/dialog/dialog.component.ts b/packages/bits/src/lib/dialog/dialog.component.ts
index 1d26d7290e..19db277a62 100644
--- a/packages/bits/src/lib/dialog/dialog.component.ts
+++ b/packages/bits/src/lib/dialog/dialog.component.ts
@@ -22,6 +22,7 @@ import { CdkScrollable, ScrollDispatcher } from "@angular/cdk/scrolling";
import { DOCUMENT } from "@angular/common";
import {
AfterViewInit,
+ afterNextRender,
Component,
ElementRef,
EventEmitter,
@@ -52,6 +53,9 @@ const FOCUSABLE_SELECTOR =
"role": "dialog",
"aria-modal": "true",
"tabindex": "-1",
+ "[attr.aria-label]": "ariaLabel || null",
+ "[attr.aria-labelledby]": "ariaLabelledby || null",
+ "[attr.aria-describedby]": "ariaDescribedby || null",
"(keyup.esc)": "escKey($event)",
"(mousedown)": "backdropMouseDown($event)",
"(mouseup)": "backdropMouseUp($event)",
@@ -84,6 +88,21 @@ export class DialogComponent implements OnInit, AfterViewInit, OnDestroy {
*/
@Input() windowClass: string;
+ /**
+ * Explicit aria-label for screen readers.
+ */
+ @Input() ariaLabel?: string;
+
+ /**
+ * ID of the element that labels the dialog window.
+ */
+ @Input() ariaLabelledby?: string;
+
+ /**
+ * ID of the element that describes the dialog window.
+ */
+ @Input() ariaDescribedby?: string;
+
/**
* Event fired on dismiss of the dialog window
*/
@@ -97,13 +116,22 @@ export class DialogComponent implements OnInit, AfterViewInit, OnDestroy {
private elRef: ElementRef,
private renderer: Renderer2,
private router: Router
- ) {}
+ ) {
+ afterNextRender(() => this.labelByHeaderTitle());
+ }
@HostListener("window:keydown.shift.tab", ["$event"])
onShiftTab(event: KeyboardEvent): void {
+ const activeElement = this.document.activeElement;
+ // When the focus is inside another overlay (e.g. a nui-overlay rendered
+ // on top of this dialog), let that overlay manage its own focus trapping.
+ if (this.isFocusInsideForeignOverlay(activeElement)) {
+ return;
+ }
+
if (
- this.elRef.nativeElement === this.document.activeElement ||
- !this.elRef.nativeElement.contains(this.document.activeElement)
+ this.elRef.nativeElement === activeElement ||
+ !this.elRef.nativeElement.contains(activeElement)
) {
this.handleFocus(event);
}
@@ -111,11 +139,32 @@ export class DialogComponent implements OnInit, AfterViewInit, OnDestroy {
@HostListener("window:keydown.tab", ["$event"])
onTab(event: KeyboardEvent): void {
- if (!this.elRef.nativeElement.contains(this.document.activeElement)) {
+ const activeElement = this.document.activeElement;
+ // When the focus is inside another overlay (e.g. a nui-overlay rendered
+ // on top of this dialog), let that overlay manage its own focus trapping.
+ if (this.isFocusInsideForeignOverlay(activeElement)) {
+ return;
+ }
+
+ if (!this.elRef.nativeElement.contains(activeElement)) {
this.handleFocus(event);
}
}
+ private isFocusInsideForeignOverlay(
+ activeElement: Element | null
+ ): boolean {
+ if (!activeElement || typeof activeElement.closest !== "function") {
+ return false;
+ }
+ const activeOverlayPane = activeElement.closest(".cdk-overlay-pane");
+ const dialogOverlayPane =
+ this.elRef.nativeElement.closest(".cdk-overlay-pane");
+ return Boolean(
+ activeOverlayPane && activeOverlayPane !== dialogOverlayPane
+ );
+ }
+
backdropMouseDown($event: any): void {
this.mouseDownOrigin = $event.target;
}
@@ -160,6 +209,22 @@ export class DialogComponent implements OnInit, AfterViewInit, OnDestroy {
}
}
+ private labelByHeaderTitle(): void {
+ if (this.ariaLabel || this.ariaLabelledby) {
+ return;
+ }
+
+ const title =
+ this.elRef.nativeElement.querySelector(".dialog-title[id]");
+ if (title) {
+ this.renderer.setAttribute(
+ this.elRef.nativeElement,
+ "aria-labelledby",
+ title.id
+ );
+ }
+ }
+
public ngOnDestroy(): void {
const body = this.document.body;
const elWithFocus = this.elWithFocus;
diff --git a/packages/bits/src/lib/expander/expander.component.html b/packages/bits/src/lib/expander/expander.component.html
index 63da20e202..ecae4b8764 100644
--- a/packages/bits/src/lib/expander/expander.component.html
+++ b/packages/bits/src/lib/expander/expander.component.html
@@ -3,7 +3,8 @@
class="nui-expander__header"
tabindex="0"
role="button"
- [attr.aria-labelledby]="'nui-expander-label-' + uniqueId"
+ [id]="headerId"
+ [attr.aria-label]="expanderToggleAriaLabel"
[attr.aria-expanded]="state === 'expanded'"
[attr.aria-disabled]="disabled"
[attr.aria-controls]="bodyId"
@@ -42,16 +43,13 @@
{{ header }}
}
-
- {{ ariaLabel || header || defaultAriaLabel }}
-
+ [attr.role]="useRegionLandmark ? 'region' : null"
+ [attr.aria-labelledby]="useRegionLandmark ? headerId : null">
@if (open) {
Covfefe
`,
@@ -123,9 +123,7 @@ describe("components >", () => {
);
usageFixture.detectChanges();
headerContentEls = usageFixture.debugElement.queryAll(
- By.css(
- ".nui-expander__header-content-wrapper>*:not(.sr-only)"
- )
+ By.css(".nui-expander__header-content-wrapper>*")
);
});
@@ -236,13 +234,13 @@ describe("components >", () => {
});
describe("accessibility >", () => {
- it("labels the header button and body region with the same standard header text", () => {
+ it("names the header button from its visible text and labels the body region with it", () => {
fixture = TestBed.createComponent(ExpanderComponent);
subject = fixture.componentInstance;
subject.header = "Standard header";
+ subject.useRegionLandmark = true;
fixture.detectChanges();
- const expectedLabelId = `nui-expander-label-${subject.uniqueId}`;
const headerEl = fixture.debugElement.query(
By.css(".nui-expander__header")
);
@@ -250,22 +248,22 @@ describe("components >", () => {
By.css(".nui-expander__body-wrapper")
);
+ // the visible header text is the accessible name, so nothing may override it
expect(
- headerEl.nativeElement.getAttribute("aria-labelledby")
- ).toBe(expectedLabelId);
- expect(
- bodyWrapperEl.nativeElement.getAttribute("aria-labelledby")
- ).toBe(expectedLabelId);
-
- const labelEl = fixture.debugElement.query(
- By.css(`#${expectedLabelId}`)
- );
- expect(labelEl.nativeElement.textContent).toContain(
+ headerEl.nativeElement.getAttribute("aria-label")
+ ).toBeNull();
+ expect(headerEl.nativeElement.textContent).toContain(
"Standard header"
);
+ expect(headerEl.nativeElement.getAttribute("id")).toBe(
+ subject.headerId
+ );
+ expect(
+ bodyWrapperEl.nativeElement.getAttribute("aria-labelledby")
+ ).toBe(subject.headerId);
});
- it("does not include projected custom header content in the accessible name", () => {
+ it("names the header button from the projected custom header content", () => {
const usageFixture = TestBed.createComponent(
ExpanderUsageWithContentComponent
);
@@ -274,7 +272,6 @@ describe("components >", () => {
usageFixture.debugElement.query(
By.directive(ExpanderComponent)
).componentInstance;
- const expectedLabelId = `nui-expander-label-${expanderSubject.uniqueId}`;
const headerEl = usageFixture.debugElement.query(
By.css(".nui-expander__header")
@@ -284,27 +281,17 @@ describe("components >", () => {
);
expect(
- headerEl.nativeElement.getAttribute("aria-labelledby")
- ).toBe(expectedLabelId);
+ headerEl.nativeElement.getAttribute("aria-label")
+ ).toBeNull();
+ expect(headerEl.nativeElement.textContent).toContain(
+ "Custom Projected Header"
+ );
expect(
bodyWrapperEl.nativeElement.getAttribute("aria-labelledby")
- ).toBe(expectedLabelId);
-
- const labelEl = usageFixture.debugElement.query(
- By.css(`#${expectedLabelId}`)
- );
- expect(labelEl.nativeElement.classList).toContain("sr-only");
+ ).toBe(expanderSubject.headerId);
expect(
- labelEl.nativeElement.querySelector(
- ".nui-expander__custom-header"
- )
+ usageFixture.debugElement.query(By.css(".sr-only"))
).toBeNull();
- expect(labelEl.nativeElement.textContent).not.toContain(
- "Custom Projected Header"
- );
- expect(labelEl.nativeElement.textContent.trim()).toBe(
- expanderSubject.defaultAriaLabel
- );
});
it("labels the header button and body region with a custom ariaLabel when there is no header", () => {
@@ -312,9 +299,9 @@ describe("components >", () => {
subject = fixture.componentInstance;
subject.header = "";
subject.ariaLabel = "Fallback accessible name";
+ subject.useRegionLandmark = true;
fixture.detectChanges();
- const expectedLabelId = `nui-expander-label-${subject.uniqueId}`;
const headerEl = fixture.debugElement.query(
By.css(".nui-expander__header")
);
@@ -322,26 +309,21 @@ describe("components >", () => {
By.css(".nui-expander__body-wrapper")
);
- expect(
- headerEl.nativeElement.getAttribute("aria-labelledby")
- ).toBe(expectedLabelId);
- expect(
- bodyWrapperEl.nativeElement.getAttribute("aria-labelledby")
- ).toBe(expectedLabelId);
-
- const srOnlyEl = fixture.debugElement.query(By.css(".sr-only"));
- expect(srOnlyEl.nativeElement.textContent.trim()).toBe(
+ expect(headerEl.nativeElement.getAttribute("aria-label")).toBe(
"Fallback accessible name"
);
+ expect(
+ bodyWrapperEl.nativeElement.getAttribute("aria-labelledby")
+ ).toBe(subject.headerId);
});
it("still has a valid accessible name from the default label when there is no header", () => {
fixture = TestBed.createComponent(ExpanderComponent);
subject = fixture.componentInstance;
subject.header = "";
+ subject.useRegionLandmark = true;
fixture.detectChanges();
- const expectedLabelId = `nui-expander-label-${subject.uniqueId}`;
const headerEl = fixture.debugElement.query(
By.css(".nui-expander__header")
);
@@ -349,20 +331,13 @@ describe("components >", () => {
By.css(".nui-expander__body-wrapper")
);
- expect(
- headerEl.nativeElement.getAttribute("aria-labelledby")
- ).toBe(expectedLabelId);
+ const headerAriaLabel =
+ headerEl.nativeElement.getAttribute("aria-label");
+ expect(headerAriaLabel).toBe(subject.expanderToggleAriaLabel);
+ expect(headerAriaLabel).toBeTruthy();
expect(
bodyWrapperEl.nativeElement.getAttribute("aria-labelledby")
- ).toBe(expectedLabelId);
-
- const srOnlyEl = fixture.debugElement.query(By.css(".sr-only"));
- expect(srOnlyEl.nativeElement.textContent.trim()).toBe(
- subject.defaultAriaLabel
- );
- expect(
- srOnlyEl.nativeElement.textContent.trim().length
- ).toBeGreaterThan(0);
+ ).toBe(subject.headerId);
});
it("points aria-controls of the header button at the body region", () => {
@@ -385,6 +360,27 @@ describe("components >", () => {
subject.bodyId
);
});
+
+ it("does not expose the body as a landmark by default", () => {
+ fixture = TestBed.createComponent(ExpanderComponent);
+ subject = fixture.componentInstance;
+ subject.header = "Standard header";
+ fixture.detectChanges();
+
+ const bodyWrapperEl = fixture.debugElement.query(
+ By.css(".nui-expander__body-wrapper")
+ );
+
+ expect(
+ bodyWrapperEl.nativeElement.getAttribute("role")
+ ).toBeNull();
+ expect(
+ bodyWrapperEl.nativeElement.getAttribute("aria-labelledby")
+ ).toBeNull();
+ expect(bodyWrapperEl.nativeElement.getAttribute("id")).toBe(
+ subject.bodyId
+ );
+ });
});
describe("toggle >", () => {
diff --git a/packages/bits/src/lib/expander/expander.component.ts b/packages/bits/src/lib/expander/expander.component.ts
index 9c016038fa..e25feb5287 100644
--- a/packages/bits/src/lib/expander/expander.component.ts
+++ b/packages/bits/src/lib/expander/expander.component.ts
@@ -66,11 +66,16 @@ export class ExpanderComponent implements AfterContentInit {
*/
@Input() hideLeftBorder: boolean = false;
/**
- * Accessible name used when `header` is not set, so the header button and body region always have a valid label.
- * Defaults to a localized fallback.
+ * Accessible label for the expander toggle.
*/
- @Input() ariaLabel: string = "";
- public readonly defaultAriaLabel: string = $localize`Expander`;
+ @Input() ariaLabel: string;
+ /**
+ * Exposes the expander body as a `region` landmark, labelled by the header.
+ * Opt-in, because several expanders on one page would otherwise flood
+ * screen reader landmark navigation with near-identical regions.
+ * @see https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
+ */
+ @Input() useRegionLandmark: boolean = false;
/**
* Use this to have expander opened by default.
*/
@@ -96,8 +101,23 @@ export class ExpanderComponent implements AfterContentInit {
public isCustomHeaderContentEmpty: boolean = false;
public uniqueId: string;
+ private readonly instanceId = ExpanderComponent.nextUniqueId++;
+
+ /** Unique id for the expander header button. */
+ public readonly headerId = `nui-expander-header-${this.instanceId}`;
+
/** Unique id for the expander body. */
- public readonly bodyId = `nui-expander-body-${ExpanderComponent.nextUniqueId++}`;
+ public readonly bodyId = `nui-expander-body-${this.instanceId}`;
+
+ /** Returns an accessible label only when no visible text content exists in the header. */
+ public get expanderToggleAriaLabel(): string | null {
+ if (this.ariaLabel) {
+ return this.ariaLabel;
+ }
+ return !this.header && this.isCustomHeaderContentEmpty
+ ? $localize`Expander toggle`
+ : null;
+ }
private actionKeys = [KEYBOARD_CODE.SPACE, KEYBOARD_CODE.ENTER].map(String);
diff --git a/packages/bits/src/lib/menu/menu-item/menu-option/menu-option.component.ts b/packages/bits/src/lib/menu/menu-item/menu-option/menu-option.component.ts
index e8104992d9..cc7c2b04f1 100644
--- a/packages/bits/src/lib/menu/menu-item/menu-option/menu-option.component.ts
+++ b/packages/bits/src/lib/menu/menu-item/menu-option/menu-option.component.ts
@@ -68,7 +68,7 @@ import { MenuItemBaseComponent } from "../menu-item/menu-item-base";
],
encapsulation: ViewEncapsulation.None,
host: {
- role: "menuitemcheckbox",
+ "role": "menuitemcheckbox",
"[attr.aria-checked]": "checked",
},
standalone: false,
diff --git a/packages/bits/src/lib/menu/menu-popup/menu-popup.component.html b/packages/bits/src/lib/menu/menu-popup/menu-popup.component.html
index 6dc5765d03..13f4da2dcc 100644
--- a/packages/bits/src/lib/menu/menu-popup/menu-popup.component.html
+++ b/packages/bits/src/lib/menu/menu-popup/menu-popup.component.html
@@ -2,6 +2,7 @@
{
diff --git a/packages/bits/src/lib/message/message.component.html b/packages/bits/src/lib/message/message.component.html
index 340217e932..42e06408d3 100644
--- a/packages/bits/src/lib/message/message.component.html
+++ b/packages/bits/src/lib/message/message.component.html
@@ -3,8 +3,13 @@
(@dismiss.done)="animationFinished($event)"
(@dismiss.start)="animationStart($event)"
class="nui-flex-container nui-flex--nowrap nui-message {{ messageClass }}"
- [ngClass]="{ 'nui-message-allow-dismiss': allowDismiss }">
-
+ [ngClass]="{ 'nui-message-allow-dismiss': allowDismiss }"
+ aria-live="polite"
+ aria-atomic="true">
+
@@ -20,6 +25,8 @@
displayStyle="action"
size="default"
icon="close"
+ ariaLabel="Dismiss message"
+ i18n-ariaLabel
(click)="dismissMessage()">
}
diff --git a/packages/bits/src/lib/overlay/overlay-component/overlay.component.spec.ts b/packages/bits/src/lib/overlay/overlay-component/overlay.component.spec.ts
index f85d0a04d9..c84986a16e 100644
--- a/packages/bits/src/lib/overlay/overlay-component/overlay.component.spec.ts
+++ b/packages/bits/src/lib/overlay/overlay-component/overlay.component.spec.ts
@@ -18,6 +18,7 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
+import { ConfigurableFocusTrapFactory } from "@angular/cdk/a11y";
import { Overlay } from "@angular/cdk/overlay";
import {
AfterViewInit,
@@ -27,7 +28,13 @@ import {
NO_ERRORS_SCHEMA,
ViewChild,
} from "@angular/core";
-import { ComponentFixture, TestBed, waitForAsync } from "@angular/core/testing";
+import {
+ ComponentFixture,
+ fakeAsync,
+ flush,
+ TestBed,
+ waitForAsync,
+} from "@angular/core/testing";
import { first } from "rxjs/operators";
import { OverlayComponent } from "./overlay.component";
@@ -164,6 +171,65 @@ describe("components >", () => {
});
});
+ describe("focus trapping", () => {
+ let focusTrapFactory: ConfigurableFocusTrapFactory;
+
+ beforeEach(() => {
+ focusTrapFactory = TestBed.inject(ConfigurableFocusTrapFactory);
+ });
+
+ it("should not create a focus trap by default", fakeAsync(() => {
+ const createSpy = spyOn(
+ focusTrapFactory,
+ "create"
+ ).and.callThrough();
+
+ component.show();
+ flush();
+
+ expect(createSpy).not.toHaveBeenCalled();
+ }));
+
+ it("should move focus into the overlay when opened with trapFocus", fakeAsync(() => {
+ const outsideButton = document.createElement("button");
+ document.body.appendChild(outsideButton);
+ outsideButton.focus();
+ component.trapFocus = true;
+
+ component.show();
+ const insideButton = document.createElement("button");
+ component
+ .getOverlayRef()
+ .overlayElement.appendChild(insideButton);
+ flush();
+
+ expect(document.activeElement).toBe(insideButton);
+ outsideButton.remove();
+ }));
+
+ it("should restore focus to the previously focused element when hidden", fakeAsync(() => {
+ const outsideButton = document.createElement("button");
+ document.body.appendChild(outsideButton);
+ outsideButton.focus();
+ component.trapFocus = true;
+
+ component.show();
+ flush();
+
+ const insideButton = document.createElement("button");
+ component
+ .getOverlayRef()
+ .overlayElement.appendChild(insideButton);
+ insideButton.focus();
+ expect(document.activeElement).toBe(insideButton);
+
+ component.hide();
+
+ expect(document.activeElement).toBe(outsideButton);
+ outsideButton.remove();
+ }));
+ });
+
describe("hide()", () => {
it("should hide dropdown", () => {
wrapperComponent.dropdown.show();
@@ -209,5 +275,35 @@ describe("components >", () => {
expect(spy).toHaveBeenCalled();
});
});
+
+ describe("A11y and ID attributes", () => {
+ it("should apply custom id, role, and ARIA attributes to overlay container", () => {
+ wrapperComponent.dropdown.idAttr = "custom-overlay-id";
+ wrapperComponent.dropdown.roleAttr = "dialog";
+ wrapperComponent.dropdown.ariaLabel = "Custom Label";
+ wrapperComponent.dropdown.ariaLabelledby = "some-title-id";
+ wrapperComponent.dropdown.ariaDescribedby = "some-desc-id";
+ wrapperComponent.dropdown.ariaModal = true;
+ wrapperComponent.dropdown.ngOnInit();
+ wrapperComponent.dropdown.show();
+
+ const overlayElement =
+ wrapperComponent.dropdown.getOverlayRef().overlayElement;
+ const innerOverlay =
+ overlayElement.querySelector("#custom-overlay-id");
+ expect(innerOverlay).toBeTruthy();
+ expect(innerOverlay?.getAttribute("role")).toBe("dialog");
+ expect(innerOverlay?.getAttribute("aria-label")).toBe(
+ "Custom Label"
+ );
+ expect(innerOverlay?.getAttribute("aria-labelledby")).toBe(
+ "some-title-id"
+ );
+ expect(innerOverlay?.getAttribute("aria-describedby")).toBe(
+ "some-desc-id"
+ );
+ expect(innerOverlay?.getAttribute("aria-modal")).toBe("true");
+ });
+ });
});
});
diff --git a/packages/bits/src/lib/overlay/overlay-component/overlay.component.ts b/packages/bits/src/lib/overlay/overlay-component/overlay.component.ts
index 976782423b..8eebf7885a 100644
--- a/packages/bits/src/lib/overlay/overlay-component/overlay.component.ts
+++ b/packages/bits/src/lib/overlay/overlay-component/overlay.component.ts
@@ -18,6 +18,10 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
+import {
+ ConfigurableFocusTrap,
+ ConfigurableFocusTrapFactory,
+} from "@angular/cdk/a11y";
import {
Overlay,
OVERLAY_DEFAULT_CONFIG,
@@ -34,8 +38,10 @@ import {
Component,
EventEmitter,
Input,
+ inject,
OnChanges,
OnDestroy,
+ OnInit,
Output,
SimpleChanges,
ViewChild,
@@ -43,6 +49,7 @@ import {
} from "@angular/core";
import set from "lodash/set";
import some from "lodash/some";
+import _uniqueId from "lodash/uniqueId";
import { Observable, Subject, Subscription } from "rxjs";
import { filter, takeUntil } from "rxjs/operators";
@@ -65,11 +72,13 @@ const isMouseEvent = (event: Event): event is MouseEvent =>
selector: "nui-overlay",
template: `
@@ -89,6 +98,7 @@ const isMouseEvent = (event: Event): event is MouseEvent =>
})
export class OverlayComponent
implements
+ OnInit,
OnDestroy,
IOverlayComponent,
AfterContentChecked,
@@ -110,12 +120,27 @@ export class OverlayComponent
/** Sets the role attribute */
@Input() roleAttr: string;
+ /** Sets the id attribute */
+ @Input() idAttr?: string;
+
/** Sets the aria-label attribute for accessibility */
@Input() ariaLabel?: string;
/** Sets the aria-labelledby attribute for accessibility */
@Input() ariaLabelledby?: string;
+ /** Sets the aria-describedby attribute for accessibility */
+ @Input() ariaDescribedby?: string;
+
+ /** Sets whether the overlay is modal (adds aria-modal="true") */
+ @Input() ariaModal?: boolean;
+ /**
+ * When enabled, keyboard focus is trapped inside the overlay content while it is open,
+ * and restored to the previously focused element once the overlay is closed.
+ * Useful for modal-like overlays (e.g. confirmation dialogs). Defaults to false so that
+ * dropdown-like usages (select, combobox) keep their current behavior.
+ */
+ @Input() public trapFocus = false;
/** Emits MouseEvent when click occurs outside Select/Combobox */
@Output() public readonly clickOutside = new EventEmitter();
@@ -137,18 +162,27 @@ export class OverlayComponent
return this.overlayService?.showing;
}
- private positionStrategySubscription: Subscription;
+ public overlayId!: string;
+
+ private positionStrategySubscription!: Subscription;
+ private focusTrap: ConfigurableFocusTrap | null = null;
+ private previouslyFocusedElement: HTMLElement | null = null;
- constructor(
- public overlayPositionService: OverlayPositionService,
- protected overlayService: OverlayService,
- protected cdkOverlay: Overlay,
- private eventBusService: EventBusService
- ) {
+ public overlayPositionService = inject(OverlayPositionService);
+ protected overlayService = inject(OverlayService);
+ protected cdkOverlay = inject(Overlay);
+ private eventBusService = inject(EventBusService);
+ private focusTrapFactory = inject(ConfigurableFocusTrapFactory);
+
+ constructor() {
this.show$ = this.overlayService.show$;
this.hide$ = this.overlayService.hide$;
}
+ public ngOnInit(): void {
+ this.overlayId = this.idAttr || _uniqueId("nui-overlay-");
+ }
+
public ngOnChanges(changes: SimpleChanges): void {
const overlayPropsToMap = ["toggleReference", "customContainer"];
@@ -170,6 +204,7 @@ export class OverlayComponent
}
public ngOnDestroy(): void {
+ this.deactivateFocusTrap();
this.overlayService.ngOnDestroy();
}
@@ -178,12 +213,14 @@ export class OverlayComponent
this.setOverlayConfig();
this.overlayService.show();
this.handleOutsideClicks();
+ this.activateFocusTrap();
setTimeout(() => this.empty$.next(this.isPopupContentEmpty())); // timeout to get the height of rendered content items
}
/** Hides Popup */
public hide(): void {
+ this.deactivateFocusTrap();
this.overlayService.hide();
this.positionStrategySubscription?.unsubscribe();
}
@@ -280,4 +317,35 @@ export class OverlayComponent
private isPopupContentEmpty(): boolean {
return this.getContentHeight() <= 10; // 10 is for 5 + 5 paddings
}
+
+ private activateFocusTrap(): void {
+ if (!this.trapFocus) {
+ return;
+ }
+
+ const overlayElement =
+ this.overlayService.getOverlayRef()?.overlayElement;
+ if (!overlayElement) {
+ return;
+ }
+
+ this.previouslyFocusedElement =
+ (document.activeElement as HTMLElement) ?? null;
+
+ this.focusTrap = this.focusTrapFactory.create(overlayElement);
+ // Defer focus until the overlay content has been rendered.
+ this.focusTrap.focusInitialElementWhenReady();
+ }
+
+ private deactivateFocusTrap(): void {
+ if (this.focusTrap) {
+ this.focusTrap.destroy();
+ this.focusTrap = null;
+ }
+
+ if (this.previouslyFocusedElement) {
+ this.previouslyFocusedElement.focus();
+ this.previouslyFocusedElement = null;
+ }
+ }
}
diff --git a/packages/bits/src/lib/overlay/overlay.module.ts b/packages/bits/src/lib/overlay/overlay.module.ts
index 6e4d643eae..d3ba4eeb2b 100644
--- a/packages/bits/src/lib/overlay/overlay.module.ts
+++ b/packages/bits/src/lib/overlay/overlay.module.ts
@@ -18,6 +18,7 @@
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
+import { A11yModule } from "@angular/cdk/a11y";
import { OverlayModule } from "@angular/cdk/overlay";
import { PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
@@ -32,7 +33,7 @@ import { OverlayPositionService } from "./overlay-position.service";
@NgModule({
declarations: [OverlayComponent],
exports: [OverlayComponent],
- imports: [OverlayModule, PortalModule, CommonModule],
+ imports: [OverlayModule, PortalModule, CommonModule, A11yModule],
providers: [OverlayPositionService],
})
export class NuiOverlayModule {}
diff --git a/packages/bits/src/lib/paginator/paginator.component.ts b/packages/bits/src/lib/paginator/paginator.component.ts
index 6fa4bf584b..12d6235e56 100644
--- a/packages/bits/src/lib/paginator/paginator.component.ts
+++ b/packages/bits/src/lib/paginator/paginator.component.ts
@@ -70,7 +70,7 @@ const containerPaddingsWithScroll = 37;
encapsulation: ViewEncapsulation.None,
providers: [PopupContainerService],
host: {
- role: "navigation",
+ "role": "navigation",
"[attr.aria-label]": "ariaLabel",
},
standalone: false,
diff --git a/packages/bits/src/lib/progress/progress.component.html b/packages/bits/src/lib/progress/progress.component.html
index 810a75bd9e..fb01d7605f 100644
--- a/packages/bits/src/lib/progress/progress.component.html
+++ b/packages/bits/src/lib/progress/progress.component.html
@@ -42,6 +42,8 @@
icon="close"
(click)="cancelProgress()"
displayStyle="action"
+ ariaLabel="Cancel"
+ i18n-ariaLabel
[nuiTooltip]="'Cancel'">
}
diff --git a/packages/bits/src/lib/progress/progress.component.ts b/packages/bits/src/lib/progress/progress.component.ts
index 3779c75733..8a40ff2740 100644
--- a/packages/bits/src/lib/progress/progress.component.ts
+++ b/packages/bits/src/lib/progress/progress.component.ts
@@ -39,7 +39,7 @@ import { _uniqueId } from "../../functions/unique-id";
styleUrls: ["./progress.component.less"],
encapsulation: ViewEncapsulation.None,
host: {
- role: "progressbar",
+ "role": "progressbar",
"aria-valuemin": "0",
"aria-valuemax": "100",
"[attr.aria-label]": "ariaLabel",
diff --git a/packages/bits/src/lib/range-filter/range-filter.component.html b/packages/bits/src/lib/range-filter/range-filter.component.html
index 6b8952faae..7134ffcd7e 100644
--- a/packages/bits/src/lib/range-filter/range-filter.component.html
+++ b/packages/bits/src/lib/range-filter/range-filter.component.html
@@ -113,9 +113,7 @@
[attr.aria-valuemin]="min()"
[attr.aria-valuemax]="displayHigh()"
[attr.aria-valuenow]="displayLow()"
- [attr.aria-label]="
- label() ? label() + ', Minimum value' : 'Minimum value'
- "
+ [attr.aria-label]="lowHandleAriaLabel"
(mousedown)="onHandleMousedown($event, 'low')"
(click)="$event.stopPropagation()"
(keydown)="onLowKeydown($event)">
@@ -139,9 +137,7 @@
"
[attr.aria-valuemax]="max()"
[attr.aria-valuenow]="displayHigh()"
- [attr.aria-label]="
- label() ? label() + ', Maximum value' : 'Maximum value'
- "
+ [attr.aria-label]="highHandleAriaLabel"
(mousedown)="onHandleMousedown($event, 'high')"
(click)="$event.stopPropagation()"
(keydown)="onHighKeydown($event)">
diff --git a/packages/bits/src/lib/range-filter/range-filter.component.ts b/packages/bits/src/lib/range-filter/range-filter.component.ts
index 71734b9e1d..3367c978a2 100644
--- a/packages/bits/src/lib/range-filter/range-filter.component.ts
+++ b/packages/bits/src/lib/range-filter/range-filter.component.ts
@@ -56,6 +56,21 @@ export class RangeFilterComponent implements OnDestroy {
public readonly rangeChange = output();
protected readonly labelId = `nui-range-filter-label-${RangeFilterComponent.nextLabelId++}`;
+
+ /** Localized label for the low/minimum slider handle */
+ protected get lowHandleAriaLabel(): string {
+ const prefix = this.label();
+ const suffix = $localize`Minimum value`;
+ return prefix ? `${prefix}, ${suffix}` : suffix;
+ }
+
+ /** Localized label for the high/maximum slider handle */
+ protected get highHandleAriaLabel(): string {
+ const prefix = this.label();
+ const suffix = $localize`Maximum value`;
+ return prefix ? `${prefix}, ${suffix}` : suffix;
+ }
+
protected readonly dragHandle = signal<"low" | "high" | null>(null);
protected readonly resolvedLow = computed(() => {
const resolvedHigh = this.snapAndClamp(
diff --git a/packages/bits/src/lib/repeat/repeat-item/repeat-item.component.html b/packages/bits/src/lib/repeat/repeat-item/repeat-item.component.html
index 607c91b0c7..2284ed04fa 100644
--- a/packages/bits/src/lib/repeat/repeat-item/repeat-item.component.html
+++ b/packages/bits/src/lib/repeat/repeat-item/repeat-item.component.html
@@ -7,8 +7,7 @@
(click)="clickable ? rowClicked.emit($event) : null"
(keydown.enter)="onKeyActivate($event)"
(keydown.space)="onKeyActivate($event)"
- [tabindex]="clickable ? 0 : -1"
->
+ [tabindex]="clickable ? 0 : -1">
diff --git a/packages/bits/src/lib/repeat/repeat-item/repeat-item.component.ts b/packages/bits/src/lib/repeat/repeat-item/repeat-item.component.ts
index bef32bc428..c8bc5d36f7 100644
--- a/packages/bits/src/lib/repeat/repeat-item/repeat-item.component.ts
+++ b/packages/bits/src/lib/repeat/repeat-item/repeat-item.component.ts
@@ -51,7 +51,8 @@ export class RepeatItemComponent {
public onKeyActivate(event: KeyboardEvent): void {
if (this.clickable) {
- this.rowClicked.emit(event as any);
+ event.preventDefault();
+ this.rowClicked.emit(event as unknown as MouseEvent);
}
}
diff --git a/packages/bits/src/lib/risk-score/risk-score.component.html b/packages/bits/src/lib/risk-score/risk-score.component.html
index e3aa8b046c..f963eb361c 100644
--- a/packages/bits/src/lib/risk-score/risk-score.component.html
+++ b/packages/bits/src/lib/risk-score/risk-score.component.html
@@ -1,8 +1,7 @@
+ [attr.aria-label]="riskScoreAriaLabel">
@if (title !== undefined) {
diff --git a/packages/bits/src/lib/select-v2/option-group/select-v2-option-group.component.ts b/packages/bits/src/lib/select-v2/option-group/select-v2-option-group.component.ts
index 5778661267..9e78fe69e0 100644
--- a/packages/bits/src/lib/select-v2/option-group/select-v2-option-group.component.ts
+++ b/packages/bits/src/lib/select-v2/option-group/select-v2-option-group.component.ts
@@ -49,7 +49,7 @@ import { IOptionedComponent } from "../types";
styleUrls: ["./select-v2-option-group.component.less"],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
- role: "group",
+ "role": "group",
"[attr.aria-label]": "ariaLabel",
},
standalone: false,
diff --git a/packages/bits/src/lib/switch/switch.component.spec.ts b/packages/bits/src/lib/switch/switch.component.spec.ts
index fde2d4a0d8..5ed87328a5 100644
--- a/packages/bits/src/lib/switch/switch.component.spec.ts
+++ b/packages/bits/src/lib/switch/switch.component.spec.ts
@@ -104,5 +104,50 @@ describe("components >", () => {
expect(switchBar.getAttribute("aria-labelledby")).toBeNull();
});
});
+
+ describe("keyboard interaction >", () => {
+ let switchBar: HTMLElement;
+
+ beforeEach(() => {
+ nuiSwitch.value = false;
+ switchFixture.detectChanges();
+ switchBar = switchFixture.nativeElement.querySelector(
+ ".nui-switch__bar"
+ ) as HTMLElement;
+ });
+
+ it("should toggle when Space is pressed (keydown)", () => {
+ switchBar.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: " ",
+ code: "Space",
+ bubbles: true,
+ })
+ );
+ expect(valueChange).toHaveBeenCalledWith(true);
+ });
+
+ it("should NOT toggle when Enter is pressed", () => {
+ switchBar.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ code: "Enter",
+ bubbles: true,
+ })
+ );
+ expect(valueChange).not.toHaveBeenCalled();
+ });
+
+ it("should NOT toggle when disabled and Space is pressed", () => {
+ nuiSwitch.setDisabledState(true);
+ switchBar.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: " ",
+ code: "Space",
+ bubbles: true,
+ })
+ );
+ expect(valueChange).not.toHaveBeenCalled();
+ });
+ });
});
});
diff --git a/packages/bits/src/lib/tabgroup/tab-group/tab-group.component.html b/packages/bits/src/lib/tabgroup/tab-group/tab-group.component.html
index f53929d39f..c8e477e130 100644
--- a/packages/bits/src/lib/tabgroup/tab-group/tab-group.component.html
+++ b/packages/bits/src/lib/tabgroup/tab-group/tab-group.component.html
@@ -4,13 +4,22 @@
[class.vertical]="vertical"
(click)="$event.preventDefault()">
@if (hasTraverse) {
+
+ [attr.aria-disabled]="!rightTraverseEnabled ? 'true' : null"
+ (click)="traverseRight()"
+ (keydown.enter)="traverseRight()"
+ (keydown.space)="$event.preventDefault(); traverseRight()">
+ [iconColor]="!rightTraverseEnabled ? 'gray' : 'default'"
+ [decorative]="true">
}
@@ -31,7 +40,7 @@
[id]="tab.tabId"
[attr.aria-controls]="tab.panelId"
[attr.tabindex]="tab.disabled ? -1 : 0"
- [attr.aria-selected]="tab.active"
+ [attr.aria-selected]="tab.active ?? false"
[attr.aria-disabled]="tab.disabled || null"
(click)="selectTab(tab)"
(keydown.enter)="selectTab(tab)"
@@ -48,13 +57,22 @@
@if (hasTraverse) {
+
+ [attr.aria-disabled]="!leftTraverseEnabled ? 'true' : null"
+ (click)="traverseLeft()"
+ (keydown.enter)="traverseLeft()"
+ (keydown.space)="$event.preventDefault(); traverseLeft()">
+ [iconColor]="!leftTraverseEnabled ? 'gray' : 'default'"
+ [decorative]="true">
}
diff --git a/packages/bits/src/lib/tabgroup/tab-group/tab-group.component.spec.ts b/packages/bits/src/lib/tabgroup/tab-group/tab-group.component.spec.ts
new file mode 100644
index 0000000000..d0d5ed4feb
--- /dev/null
+++ b/packages/bits/src/lib/tabgroup/tab-group/tab-group.component.spec.ts
@@ -0,0 +1,140 @@
+// © 2022 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.
+
+import { Component } from "@angular/core";
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+
+import { NuiTabsModule } from "../tabs.module";
+
+@Component({
+ selector: "nui-test-tab-group",
+ template: `
+
+ Content 1
+ Content 2
+ Content 3
+
+ `,
+ imports: [NuiTabsModule],
+})
+class TestTabGroupComponent {}
+
+describe("components >", () => {
+ describe("tab-group >", () => {
+ let fixture: ComponentFixture
;
+ let el: HTMLElement;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ imports: [TestTabGroupComponent],
+ });
+
+ fixture = TestBed.createComponent(TestTabGroupComponent);
+ fixture.detectChanges();
+ el = fixture.nativeElement;
+ });
+
+ describe("ARIA attributes >", () => {
+ it("should have role=tablist on the tabs container", () => {
+ const tablist = el.querySelector(".nui-tabs__container");
+ expect(tablist?.getAttribute("role")).toBe("tablist");
+ });
+
+ it("should have role=tab on each tab link", () => {
+ const tabLinks = el.querySelectorAll(".tab-link");
+ tabLinks.forEach(link => {
+ expect(link.getAttribute("role")).toBe("tab");
+ });
+ });
+
+ it("should set aria-selected=true on the active tab", () => {
+ const tabLinks = el.querySelectorAll(".tab-link");
+ expect(tabLinks[0].getAttribute("aria-selected")).toBe("true");
+ });
+
+ it("should set aria-selected=false on inactive tabs", () => {
+ const tabLinks = el.querySelectorAll(".tab-link");
+ expect(tabLinks[1].getAttribute("aria-selected")).toBe("false");
+ });
+
+ it("should set aria-disabled on disabled tab", () => {
+ const tabLinks = el.querySelectorAll(".tab-link");
+ expect(tabLinks[2].getAttribute("aria-disabled")).toBe("true");
+ });
+
+ it("should not set aria-disabled on enabled tabs", () => {
+ const tabLinks = el.querySelectorAll(".tab-link");
+ expect(tabLinks[0].getAttribute("aria-disabled")).toBeNull();
+ });
+ });
+
+ describe("tabindex >", () => {
+ it("should have tabindex=0 on enabled tab links", () => {
+ const tabLinks = el.querySelectorAll(".tab-link");
+ expect(tabLinks[0].getAttribute("tabindex")).toBe("0");
+ expect(tabLinks[1].getAttribute("tabindex")).toBe("0");
+ });
+
+ it("should have tabindex=-1 on disabled tab link", () => {
+ const tabLinks = el.querySelectorAll(".tab-link");
+ expect(tabLinks[2].getAttribute("tabindex")).toBe("-1");
+ });
+ });
+
+ describe("keyboard interaction >", () => {
+ it("should select tab on Enter keydown", () => {
+ const tabLinks = el.querySelectorAll(".tab-link");
+
+ // Second tab is initially inactive
+ expect(tabLinks[1].getAttribute("aria-selected")).toBe("false");
+
+ tabLinks[1].dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "Enter",
+ code: "Enter",
+ bubbles: true,
+ })
+ );
+ fixture.detectChanges();
+
+ expect(tabLinks[1].getAttribute("aria-selected")).toBe("true");
+ expect(tabLinks[0].getAttribute("aria-selected")).toBe("false");
+ });
+
+ it("should NOT select disabled tab on Enter keydown", () => {
+ const tabLinks = el.querySelectorAll(".tab-link");
+ const disabledTab = tabLinks[2];
+
+ disabledTab.dispatchEvent(
+ new KeyboardEvent("keydown", {
+ key: "Enter",
+ code: "Enter",
+ bubbles: true,
+ })
+ );
+ fixture.detectChanges();
+
+ expect(disabledTab.getAttribute("aria-selected")).toBe("false");
+ // First tab should still be active
+ expect(tabLinks[0].getAttribute("aria-selected")).toBe("true");
+ });
+ });
+ });
+});
diff --git a/packages/bits/src/lib/tabgroup/tab-heading-group/tab-heading-group.component.html b/packages/bits/src/lib/tabgroup/tab-heading-group/tab-heading-group.component.html
index 7a5491ab91..e081c1eff5 100644
--- a/packages/bits/src/lib/tabgroup/tab-heading-group/tab-heading-group.component.html
+++ b/packages/bits/src/lib/tabgroup/tab-heading-group/tab-heading-group.component.html
@@ -3,12 +3,20 @@
+ [attr.aria-disabled]="!rightTraverseEnabled ? 'true' : null"
+ (click)="traverseRight()"
+ (keydown.enter)="traverseRight()"
+ (keydown.space)="$event.preventDefault(); traverseRight()">
+ [iconColor]="!rightTraverseEnabled ? 'disabled-gray' : 'gray'"
+ [decorative]="true">
}
@@ -23,12 +31,20 @@
+ [attr.aria-disabled]="!leftTraverseEnabled ? 'true' : null"
+ (click)="traverseLeft()"
+ (keydown.enter)="traverseLeft()"
+ (keydown.space)="$event.preventDefault(); traverseLeft()">
+ [iconColor]="!leftTraverseEnabled ? 'disabled-gray' : 'gray'"
+ [decorative]="true">
}
diff --git a/packages/bits/src/lib/textbox/textbox-number/textbox-number.component.spec.ts b/packages/bits/src/lib/textbox/textbox-number/textbox-number.component.spec.ts
index 73960aa315..b4acede22c 100644
--- a/packages/bits/src/lib/textbox/textbox-number/textbox-number.component.spec.ts
+++ b/packages/bits/src/lib/textbox/textbox-number/textbox-number.component.spec.ts
@@ -77,6 +77,25 @@ describe("components >", () => {
inputControl = debugElement.query(By.css(".input-control"));
});
+ it("should expose number semantics and ARIA values on the native input", () => {
+ fixture.componentRef.setInput("minValue", 1);
+ fixture.componentRef.setInput("maxValue", 10);
+ fixture.componentRef.setInput("value", 5);
+ fixture.detectChanges();
+
+ const hostElement = fixture.nativeElement as HTMLElement;
+ const inputElement = hostElement.querySelector(
+ "input"
+ ) as HTMLInputElement;
+
+ expect(hostElement.getAttribute("role")).toBeNull();
+ expect(hostElement.getAttribute("aria-valuenow")).toBeNull();
+ expect(inputElement.type).toBe("number");
+ expect(inputElement.getAttribute("aria-valuemin")).toBe("1");
+ expect(inputElement.getAttribute("aria-valuemax")).toBe("10");
+ expect(inputElement.getAttribute("aria-valuenow")).toBe("5");
+ });
+
describe("onValueChange > ", () => {
it("keeps invalid input", () => {
testComponent.onValueChange("yyy");
diff --git a/packages/bits/src/lib/textbox/textbox-number/textbox-number.component.ts b/packages/bits/src/lib/textbox/textbox-number/textbox-number.component.ts
index 245706150b..1a75cb413e 100644
--- a/packages/bits/src/lib/textbox/textbox-number/textbox-number.component.ts
+++ b/packages/bits/src/lib/textbox/textbox-number/textbox-number.component.ts
@@ -70,11 +70,7 @@ import { NuiFormFieldControl } from "../../form-field/public-api";
styleUrls: ["../textbox.component.less", "./textbox-number.component.less"],
encapsulation: ViewEncapsulation.None,
host: {
- "role": "spinbutton",
- "[attr.aria-label]": "ariaLabel",
- "[attr.aria-valuemin]": "minValue || null",
- "[attr.aria-valuemax]": "maxValue || null",
- "[attr.aria-valuenow]": "value || 0",
+ class: "nui-textbox-number-host",
},
standalone: false,
})
diff --git a/packages/bits/src/lib/toast/toast.component.html b/packages/bits/src/lib/toast/toast.component.html
index 7ba4c18b4a..b8433db746 100644
--- a/packages/bits/src/lib/toast/toast.component.html
+++ b/packages/bits/src/lib/toast/toast.component.html
@@ -27,6 +27,8 @@
displayStyle="action"
size="default"
icon="close"
+ ariaLabel="Dismiss notification"
+ i18n-ariaLabel
(click)="remove()">
}
diff --git a/packages/bits/src/lib/toolbar/toolbar-keyboard.service.spec.ts b/packages/bits/src/lib/toolbar/toolbar-keyboard.service.spec.ts
index 3fbf142473..bebc9a1c26 100644
--- a/packages/bits/src/lib/toolbar/toolbar-keyboard.service.spec.ts
+++ b/packages/bits/src/lib/toolbar/toolbar-keyboard.service.spec.ts
@@ -157,7 +157,7 @@ describe("Services > ", () => {
afterEach(() => {
const items = service["toolbarItems"];
- items.forEach((item) => item.removeAttribute("disabled"));
+ items.forEach(item => item.removeAttribute("disabled"));
});
it("should skip a disabled item on pressing right arrow button", () => {
diff --git a/packages/bits/src/lib/toolbar/toolbar-keyboard.service.ts b/packages/bits/src/lib/toolbar/toolbar-keyboard.service.ts
index b6780589c3..cfaf3bcace 100644
--- a/packages/bits/src/lib/toolbar/toolbar-keyboard.service.ts
+++ b/packages/bits/src/lib/toolbar/toolbar-keyboard.service.ts
@@ -123,7 +123,7 @@ export class ToolbarKeyboardService {
// items disabled after the list was built are excluded here rather than at build time
private get enabledItems(): HTMLElement[] {
return this.toolbarItems.filter(
- (el) =>
+ el =>
!el.hasAttribute("disabled") &&
el.getAttribute("aria-disabled") !== "true"
);
diff --git a/packages/charts/e2e/chart/time-frame-bar-test.po.ts b/packages/charts/e2e/chart/time-frame-bar-test.po.ts
index 7060eb19d0..aa4b4b07cc 100644
--- a/packages/charts/e2e/chart/time-frame-bar-test.po.ts
+++ b/packages/charts/e2e/chart/time-frame-bar-test.po.ts
@@ -46,9 +46,9 @@ export class TimeFrameBarTestPage {
}
public async removeDelay(): Promise {
- if (await this.delayCheckbox.isChecked()) {
- await this.delayCheckbox.click();
- }
+ await expect(this.delayCheckbox).toBeChecked();
+ await this.delayCheckbox.uncheck();
+ await expect(this.delayCheckbox).not.toBeChecked();
}
public async waitUntilReady(): Promise {
diff --git a/packages/charts/src/chart-marker/chart-marker.component.html b/packages/charts/src/chart-marker/chart-marker.component.html
index 05bea20c50..0f889aa1dd 100644
--- a/packages/charts/src/chart-marker/chart-marker.component.html
+++ b/packages/charts/src/chart-marker/chart-marker.component.html
@@ -2,7 +2,6 @@
[attr.width]="width"
[attr.height]="height"
[attr.viewBox]="viewBox"
- aria-hidden="true"
->
+ aria-hidden="true">
diff --git a/packages/charts/src/legend/legend.component.ts b/packages/charts/src/legend/legend.component.ts
index 9cf56b6ff1..2860433188 100644
--- a/packages/charts/src/legend/legend.component.ts
+++ b/packages/charts/src/legend/legend.component.ts
@@ -33,7 +33,7 @@ import { LegendOrientation } from "./types";
@Component({
selector: "nui-legend",
host: {
- class: "d-inline-block",
+ "class": "d-inline-block",
"[attr.role]": "interactive ? 'group' : 'list'",
"[attr.aria-label]": "ariaLabel",
},
diff --git a/packages/charts/src/styles/nui-charts.less b/packages/charts/src/styles/nui-charts.less
index 21b924e1f5..be15594fb9 100644
--- a/packages/charts/src/styles/nui-charts.less
+++ b/packages/charts/src/styles/nui-charts.less
@@ -84,6 +84,10 @@
cursor: default; // to prevent text selection cursor
.setCssVariable(background-color, nui-color-bg-content);
+ &:focus-visible {
+ .focus-outline();
+ }
+
&:not(.nui-legend-series--horizontal):not(:last-child) {
margin-bottom: 1px;
}
diff --git a/packages/dashboards/examples/src/components/docs/widget-types/view-components/view-components-docs.component.html b/packages/dashboards/examples/src/components/docs/widget-types/view-components/view-components-docs.component.html
index 654adaf90e..836d5d19ed 100644
--- a/packages/dashboards/examples/src/components/docs/widget-types/view-components/view-components-docs.component.html
+++ b/packages/dashboards/examples/src/components/docs/widget-types/view-components/view-components-docs.component.html
@@ -22,8 +22,7 @@ Installation
+ [code]="installationSnippet">
@@ -44,16 +43,14 @@ Playground — All States
+ exampleTitle="KPI Tile View - Playground">
Interactive with Custom Formatting
+ exampleTitle="KPI Tile View - Interactive">
@@ -77,8 +74,7 @@ Playground — All Chart Types
+ exampleTitle="Proportional Chart View - Playground">
@@ -263,5 +259,4 @@ nui-proportional-chart-view Outputs
IProportionalDataItem Interface
+ [code]="proportionalDataItemSnippet">
diff --git a/packages/dashboards/examples/src/google-books.interceptor.ts b/packages/dashboards/examples/src/google-books.interceptor.ts
index 682fa5abb6..045207d19e 100644
--- a/packages/dashboards/examples/src/google-books.interceptor.ts
+++ b/packages/dashboards/examples/src/google-books.interceptor.ts
@@ -48,7 +48,7 @@ export class GoogleBooksInterceptor implements HttpInterceptor {
infoLink: "https://books.google.com/books?id=5MQFrgEACAAJ",
},
},
- zpvysRGsBlwC: {
+ "zpvysRGsBlwC": {
kind: "books#volume",
id: "zpvysRGsBlwC",
volumeInfo: {
diff --git a/packages/dashboards/src/docs/development/summary.json b/packages/dashboards/src/docs/development/summary.json
index 524769108f..60b21f8795 100644
--- a/packages/dashboards/src/docs/development/summary.json
+++ b/packages/dashboards/src/docs/development/summary.json
@@ -152,12 +152,12 @@
"title": "Embedded Content"
},
{
- "title": "Drilldown",
- "file": "../pages/widget-types/drilldown.md"
+ "file": "../pages/widget-types/drilldown.md",
+ "title": "Drilldown"
},
{
- "title": "Standalone View Components",
- "file": "../pages/widget-types/view-components.md"
+ "file": "../pages/widget-types/view-components.md",
+ "title": "Standalone View Components"
}
],
"file": "../pages/widget-types/overview.md",
diff --git a/packages/dashboards/src/docs/production/summary.json b/packages/dashboards/src/docs/production/summary.json
index 524769108f..60b21f8795 100644
--- a/packages/dashboards/src/docs/production/summary.json
+++ b/packages/dashboards/src/docs/production/summary.json
@@ -152,12 +152,12 @@
"title": "Embedded Content"
},
{
- "title": "Drilldown",
- "file": "../pages/widget-types/drilldown.md"
+ "file": "../pages/widget-types/drilldown.md",
+ "title": "Drilldown"
},
{
- "title": "Standalone View Components",
- "file": "../pages/widget-types/view-components.md"
+ "file": "../pages/widget-types/view-components.md",
+ "title": "Standalone View Components"
}
],
"file": "../pages/widget-types/overview.md",
diff --git a/packages/dashboards/src/lib/components/list-widget/list-elements/list-group-item/list-group-item.component.html b/packages/dashboards/src/lib/components/list-widget/list-elements/list-group-item/list-group-item.component.html
index fe7be17418..da0473198d 100644
--- a/packages/dashboards/src/lib/components/list-widget/list-elements/list-group-item/list-group-item.component.html
+++ b/packages/dashboards/src/lib/components/list-widget/list-elements/list-group-item/list-group-item.component.html
@@ -5,8 +5,7 @@
[ngClass]="{ 'widget-medium': isMedium, 'widget-small': isSmall }"
(click)="onButtonClick()"
(keydown.enter)="onButtonClick()"
- (keydown.space)="$event.preventDefault(); onButtonClick()"
->
+ (keydown.space)="$event.preventDefault(); onButtonClick()">
{{ label }}
@@ -15,8 +14,7 @@
+ aria-hidden="true">
{{
status.value
}}
diff --git a/packages/dashboards/src/lib/components/list-widget/list-elements/list-group-item/list-group-item.component.less b/packages/dashboards/src/lib/components/list-widget/list-elements/list-group-item/list-group-item.component.less
index 064bca03e0..8063b25002 100644
--- a/packages/dashboards/src/lib/components/list-widget/list-elements/list-group-item/list-group-item.component.less
+++ b/packages/dashboards/src/lib/components/list-widget/list-elements/list-group-item/list-group-item.component.less
@@ -8,6 +8,10 @@
align-items: center;
flex-wrap: wrap;
+ &:focus-visible {
+ .focus-outline();
+ }
+
&__label {
width: 25%;
max-width: 100%;
diff --git a/packages/dashboards/src/lib/components/list-widget/list-elements/list-leaf-item/list-leaf-item.component.less b/packages/dashboards/src/lib/components/list-widget/list-elements/list-leaf-item/list-leaf-item.component.less
index 7c64ad77a4..ad6b179a8e 100644
--- a/packages/dashboards/src/lib/components/list-widget/list-elements/list-leaf-item/list-leaf-item.component.less
+++ b/packages/dashboards/src/lib/components/list-widget/list-elements/list-leaf-item/list-leaf-item.component.less
@@ -1,5 +1,7 @@
@import (reference) "@nova-ui/bits/sdk/less/nui-framework-variables";
+@import (reference) "@nova-ui/bits/sdk/less/mixins";
+
.nui-list-leaf-item {
width: 100%;
display: flex;
@@ -7,6 +9,10 @@
align-items: center;
padding: @nui-space-xs 0;
+ &:focus-visible {
+ .focus-outline();
+ }
+
&__info-container {
display: flex;
height: 40px;
diff --git a/packages/dashboards/src/lib/components/risk-score-tile/risk-score-tile.component.html b/packages/dashboards/src/lib/components/risk-score-tile/risk-score-tile.component.html
index 8e21eb11f4..8fc3016028 100644
--- a/packages/dashboards/src/lib/components/risk-score-tile/risk-score-tile.component.html
+++ b/packages/dashboards/src/lib/components/risk-score-tile/risk-score-tile.component.html
@@ -4,7 +4,13 @@
} @if (!widgetData?.link) {
-
+
}
diff --git a/packages/dashboards/src/lib/components/table-widget/table-widget.component.html b/packages/dashboards/src/lib/components/table-widget/table-widget.component.html
index a7016b3148..18365a2c89 100644
--- a/packages/dashboards/src/lib/components/table-widget/table-widget.component.html
+++ b/packages/dashboards/src/lib/components/table-widget/table-widget.component.html
@@ -50,15 +50,17 @@
{{ column.label }}
@@ -87,26 +89,26 @@
*nuiCellDef="let element"
[style.width.px]="columnsWidthMap.get(column.id)"
[style.max-width.px]="
- columnsWidthMap.get(column.id)
- "
+ columnsWidthMap.get(column.id)
+ "
[style.min-width.px]="
- columnsWidthMap.get(column.id)
- "
+ columnsWidthMap.get(column.id)
+ "
[alignment]="getColumnAlignment(column)">
@if (element[column.id] && column.formatter) {
+ componentPortal.attached($event)
+ ">
}
@@ -118,7 +120,13 @@
nui-row
*nuiRowDef="let row; columns: headers"
(click)="onInteraction(row, $event)"
- [attr.role]="interactive ? 'button' : ''"
+ (keydown.enter)="onInteraction(row, $event)"
+ (keydown.space)="
+ interactive && $event.preventDefault();
+ onInteraction(row, $event)
+ "
+ [attr.role]="interactive ? 'button' : null"
+ [attr.tabindex]="interactive ? 0 : null"
density="tiny"
[rowObject]="row.__record"
[clickableRow]="clickableRow">
diff --git a/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.html b/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.html
index 79ee27495f..7f40776adb 100644
--- a/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.html
+++ b/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.html
@@ -7,7 +7,13 @@
} @if (!link) {
-
+
diff --git a/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.less b/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.less
index 09261ba0cd..d96cf335e1 100644
--- a/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.less
+++ b/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.less
@@ -19,6 +19,10 @@
height: 100%;
width: 100%;
+ &:focus-visible {
+ .focus-outline();
+ }
+
&__background {
height: 100%;
width: 100%;
@@ -85,11 +89,15 @@
width: 100%;
height: 100%;
}
- &__zoom-container:has(.nui-kpi-indicator__zoom-container__row:nth-child(3)) {
+ &__zoom-container:has(
+ .nui-kpi-indicator__zoom-container__row:nth-child(3)
+ ) {
grid-template-rows: 30% 40% 30%;
}
- &__zoom-container:not(:has(.nui-kpi-indicator__zoom-container__row:nth-child(3))) {
+ &__zoom-container:not(
+ :has(.nui-kpi-indicator__zoom-container__row:nth-child(3))
+ ) {
grid-template-rows: 30% 70%;
}
&--interactive {
diff --git a/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.spec.ts b/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.spec.ts
index eb88d8c692..ed5c2fb4c0 100644
--- a/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.spec.ts
+++ b/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.spec.ts
@@ -203,6 +203,59 @@ describe("KpiTileViewComponent", () => {
expect(div).toBeTruthy();
});
+ it("should expose keyboard interaction for an interactive tile without a link", () => {
+ host.value = 42;
+ host.interactive = true;
+ host.link = "";
+ fixture.detectChanges();
+
+ const tile = fixture.nativeElement.querySelector(
+ "div.nui-kpi-indicator"
+ ) as HTMLElement;
+ const emitSpy = spyOn(component.tileClick, "emit");
+
+ expect(tile.getAttribute("role")).toBe("button");
+ expect(tile.getAttribute("tabindex")).toBe("0");
+
+ tile.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", cancelable: true })
+ );
+ fixture.detectChanges();
+ expect(emitSpy).toHaveBeenCalledTimes(1);
+
+ const spaceEvent = new KeyboardEvent("keydown", {
+ key: " ",
+ cancelable: true,
+ });
+ tile.dispatchEvent(spaceEvent);
+ fixture.detectChanges();
+
+ expect(spaceEvent.defaultPrevented).toBe(true);
+ expect(emitSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it("should not expose keyboard interaction when the tile is not interactive", () => {
+ host.value = 42;
+ host.interactive = false;
+ host.link = "";
+ fixture.detectChanges();
+
+ const tile = fixture.nativeElement.querySelector(
+ "div.nui-kpi-indicator"
+ ) as HTMLElement;
+ const emitSpy = spyOn(component.tileClick, "emit");
+
+ expect(tile.hasAttribute("role")).toBe(false);
+ expect(tile.hasAttribute("tabindex")).toBe(false);
+
+ tile.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", cancelable: true })
+ );
+ fixture.detectChanges();
+
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+
it("should compute text color from background when no explicit textColor", () => {
host.value = 42;
host.backgroundColor = "#ff0000";
diff --git a/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.ts b/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.ts
index 4fc049652f..e0da8c797e 100644
--- a/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.ts
+++ b/packages/dashboards/src/lib/components/views/kpi-tile-view/kpi-tile-view.component.ts
@@ -100,7 +100,7 @@ export class KpiTileViewComponent {
public getScaleBroker(id: string): IKpiTileViewBroker | undefined {
if (this.syncValuesBroker) {
- return this.syncValuesBroker.find((b) => b.id === id);
+ return this.syncValuesBroker.find(b => b.id === id);
}
return undefined;
}
diff --git a/packages/dashboards/src/lib/components/views/proportional-chart-view/proportional-chart-view.component.ts b/packages/dashboards/src/lib/components/views/proportional-chart-view/proportional-chart-view.component.ts
index dfcbd6cf04..78b7553fc0 100644
--- a/packages/dashboards/src/lib/components/views/proportional-chart-view/proportional-chart-view.component.ts
+++ b/packages/dashboards/src/lib/components/views/proportional-chart-view/proportional-chart-view.component.ts
@@ -181,7 +181,7 @@ export class ProportionalChartViewComponent
if (!this.interactive) {
return;
}
- const item = this.data.find((d) => d.id === legendSeries?.id);
+ const item = this.data.find(d => d.id === legendSeries?.id);
if (item) {
this.itemClick.emit(item);
}
@@ -231,10 +231,8 @@ export class ProportionalChartViewComponent
this.chartTypeSubscription$ = this.chartAssist.chart
.getEventBus()
.getStream(SELECT_DATA_POINT_EVENT)
- .subscribe((event) => {
- const item = this.data.find(
- (d) => d.id === event.data.seriesId
- );
+ .subscribe(event => {
+ const item = this.data.find(d => d.id === event.data.seriesId);
if (item) {
this.onInteraction({ id: item.id });
}
@@ -317,7 +315,7 @@ export class ProportionalChartViewComponent
private updateChartColors(): void {
let colorProvider: IValueProvider
;
- const dataColors = this.data?.map((v) => v.color);
+ const dataColors = this.data?.map(v => v.color);
const configColors = this.colors;
if (some(dataColors)) {
@@ -343,7 +341,7 @@ export class ProportionalChartViewComponent
private getDataDrivenColorProvider(): IValueProvider {
const dataColors = this.data
- ?.map((v) => v.color)
+ ?.map(v => v.color)
.filter((v): v is string => !!v);
if (dataColors.length === this.data.length) {
@@ -370,7 +368,7 @@ export class ProportionalChartViewComponent
}
private mapDataToSeries(): Array> {
- return this.data.map((item) => ({
+ return this.data.map(item => ({
id: item.id,
name: item.name,
data: [{ value: item.value }],