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. +

+
+ + + + +
+ + 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 @@ - +

Are you sure you want to quit?
- +
@@ -180,14 +185,16 @@ @@ -50,6 +54,8 @@ type="button" displayStyle="action" icon="undo" + ariaLabel="Undo" + i18n-ariaLabel (click)="onUndo()" nuiTooltip="Undo" i18n-nuiTooltip> @@ -60,6 +66,8 @@ type="button" displayStyle="action" icon="reset" + ariaLabel="Reset to default" + i18n-ariaLabel (click)="onClear()" nuiTooltip="Reset to default" i18n-nuiTooltip> diff --git a/packages/bits/src/lib/date-picker/date-picker-day-picker.component.html b/packages/bits/src/lib/date-picker/date-picker-day-picker.component.html index af4e569f99..a050745dd7 100644 --- a/packages/bits/src/lib/date-picker/date-picker-day-picker.component.html +++ b/packages/bits/src/lib/date-picker/date-picker-day-picker.component.html @@ -52,7 +52,7 @@ } @for (label of labels; track label) {
- {{ label.abbr }} + {{ label.abbr }}
} diff --git a/packages/bits/src/lib/date-picker/date-picker-inner.component.html b/packages/bits/src/lib/date-picker/date-picker-inner.component.html index 2249a983e5..26033a8197 100644 --- a/packages/bits/src/lib/date-picker/date-picker-inner.component.html +++ b/packages/bits/src/lib/date-picker/date-picker-inner.component.html @@ -3,8 +3,7 @@ class="nui-datepicker-inner" [attr.role]="role" aria-label="Calendar" - i18n-aria-label="@@calendar" -> + i18n-aria-label="@@calendar">
} 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 { } + + + @@ -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) {

Custom Projected Header

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 }],