diff --git a/packages/bits/src/lib/button/button.component.html b/packages/bits/src/lib/button/button.component.html
index e512ee974..a86a9e49b 100644
--- a/packages/bits/src/lib/button/button.component.html
+++ b/packages/bits/src/lib/button/button.component.html
@@ -2,6 +2,9 @@
*ngIf="isBusy"
[ngStyle]="getRippleContainerStyle()"
class="nui-button-ripple-container"
+ aria-live="polite"
+ aria-atomic="true"
+ aria-label="Loading"
>
diff --git a/packages/bits/src/lib/button/button.component.spec.ts b/packages/bits/src/lib/button/button.component.spec.ts
index ae417d53d..a4cec88e8 100644
--- a/packages/bits/src/lib/button/button.component.spec.ts
+++ b/packages/bits/src/lib/button/button.component.spec.ts
@@ -76,6 +76,45 @@ class TestAppButtonInRepeaterComponent implements OnInit {
}
}
+@Component({
+ selector: "nui-button-icon-only",
+ template: ` `,
+ standalone: false,
+})
+class TestAppIconOnlyButtonComponent {}
+
+@Component({
+ selector: "nui-button-icon-only-with-aria",
+ template: ` `,
+ standalone: false,
+})
+class TestAppIconOnlyButtonWithAriaComponent {}
+
+@Component({
+ selector: "nui-button-busy",
+ template: ` `,
+ standalone: false,
+})
+class TestAppBusyButtonComponent {
+ public isBusy = false;
+}
+
+@Component({
+ selector: "nui-button-disabled",
+ template: ` `,
+ standalone: false,
+})
+class TestAppDisabledButtonComponent {
+ public disabled = false;
+}
+
+@Component({
+ selector: "nui-button-repeat-keyboard",
+ template: ` `,
+ standalone: false,
+})
+class TestAppRepeatKeyboardButtonComponent {}
+
describe("components >", () => {
describe("button >", () => {
const SIZE_LARGE = ButtonSizeType.large;
@@ -93,6 +132,11 @@ describe("components >", () => {
TestAppButtonOnDivNoTypeComponent,
TestAppButtonInRepeaterComponent,
TestAppButtonComponent,
+ TestAppIconOnlyButtonComponent,
+ TestAppIconOnlyButtonWithAriaComponent,
+ TestAppBusyButtonComponent,
+ TestAppDisabledButtonComponent,
+ TestAppRepeatKeyboardButtonComponent,
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
providers: [LoggerService],
@@ -209,5 +253,204 @@ describe("components >", () => {
expect(click).toHaveBeenCalledTimes(2);
}));
});
+
+ describe("accessibility >", () => {
+ describe("icon-only buttons >", () => {
+ it("should warn when icon-only button lacks aria-label", () => {
+ const warnSpy = spyOnProperty(logger, "warn", "get").and.callThrough();
+ fixture = TestBed.createComponent(TestAppIconOnlyButtonComponent);
+ de = fixture.debugElement;
+ subject = de.children[0].componentInstance;
+ subject.icon = "add";
+ subject.isEmpty = true;
+ fixture.detectChanges();
+
+ expect(warnSpy).toHaveBeenCalledWith(
+ "Icon-only button detected without aria-label. Please provide a meaningful aria-label for accessibility.",
+ jasmine.any(Object)
+ );
+ });
+
+ it("should not warn when icon-only button has aria-label", () => {
+ const warnSpy = spyOnProperty(logger, "warn", "get").and.callThrough();
+ fixture = TestBed.createComponent(TestAppIconOnlyButtonWithAriaComponent);
+ de = fixture.debugElement;
+ subject = de.children[0].componentInstance;
+ fixture.detectChanges();
+
+ expect(warnSpy).not.toHaveBeenCalled();
+ });
+
+ it("should use provided aria-label for icon-only buttons", () => {
+ fixture = TestBed.createComponent(TestAppIconOnlyButtonWithAriaComponent);
+ de = fixture.debugElement;
+ subject = de.children[0].componentInstance;
+ fixture.detectChanges();
+
+ expect(subject.ariaIconLabel).toBe("Add item");
+ });
+ });
+
+ describe("busy state >", () => {
+ let busyFixture: ComponentFixture;
+ let busyComponent: TestAppBusyButtonComponent;
+
+ beforeEach(() => {
+ busyFixture = TestBed.createComponent(TestAppBusyButtonComponent);
+ busyComponent = busyFixture.componentInstance;
+ busyFixture.detectChanges();
+ });
+
+ it("should add aria-live and aria-atomic to busy container", () => {
+ busyComponent.isBusy = true;
+ busyFixture.detectChanges();
+
+ const rippleContainer = busyFixture.debugElement.query(
+ By.css(".nui-button-ripple-container")
+ );
+ expect(rippleContainer).toBeTruthy();
+ expect(rippleContainer.nativeElement.getAttribute("aria-live")).toBe("polite");
+ expect(rippleContainer.nativeElement.getAttribute("aria-atomic")).toBe("true");
+ expect(rippleContainer.nativeElement.getAttribute("aria-label")).toBe("Loading");
+ });
+
+ it("should not show ripple container when not busy", () => {
+ busyComponent.isBusy = false;
+ busyFixture.detectChanges();
+
+ const rippleContainer = busyFixture.debugElement.query(
+ By.css(".nui-button-ripple-container")
+ );
+ expect(rippleContainer).toBeFalsy();
+ });
+
+ it("should set aria-busy attribute based on isBusy state", () => {
+ const buttonElement = busyFixture.debugElement.query(By.css("button"));
+
+ // Test busy state
+ busyComponent.isBusy = true;
+ busyFixture.detectChanges();
+ expect(buttonElement.nativeElement.getAttribute("aria-busy")).toBe("true");
+
+ // Test non-busy state
+ busyComponent.isBusy = false;
+ busyFixture.detectChanges();
+ expect(buttonElement.nativeElement.hasAttribute("aria-busy")).toBeFalsy();
+ });
+ });
+
+ describe("disabled state >", () => {
+ let disabledFixture: ComponentFixture;
+ let disabledComponent: TestAppDisabledButtonComponent;
+ let disabledSubject: ButtonComponent;
+
+ beforeEach(() => {
+ disabledFixture = TestBed.createComponent(TestAppDisabledButtonComponent);
+ disabledComponent = disabledFixture.componentInstance;
+ disabledSubject = disabledFixture.debugElement.children[0].componentInstance;
+ disabledFixture.detectChanges();
+ });
+
+ it("should set aria-disabled to true when button is disabled", () => {
+ disabledComponent.disabled = true;
+ disabledFixture.detectChanges();
+
+ const buttonElement = disabledFixture.debugElement.query(By.css("button")).nativeElement;
+ buttonElement.disabled = true; // Simulate disabled state
+
+ expect(disabledSubject.ariaDisabled).toBe("true");
+ });
+
+ it("should not set aria-disabled when button is enabled", () => {
+ disabledComponent.disabled = false;
+ disabledFixture.detectChanges();
+
+ expect(disabledSubject.ariaDisabled).toBeNull();
+ });
+ });
+
+ describe("keyboard repeat functionality >", () => {
+ let keyboardFixture: ComponentFixture;
+ let keyboardSubject: ButtonComponent;
+ let element: HTMLButtonElement;
+
+ beforeEach(() => {
+ keyboardFixture = TestBed.createComponent(TestAppRepeatKeyboardButtonComponent);
+ keyboardSubject = keyboardFixture.debugElement.children[0].componentInstance;
+ keyboardFixture.detectChanges();
+ element = (keyboardSubject).el.nativeElement;
+ });
+
+ it("should trigger repeat functionality with Space key", fakeAsync(() => {
+ const click = spyOn(element, "click").and.callThrough();
+
+ // Simulate keydown event
+ const keydownEvent = new KeyboardEvent("keydown", { code: "Space" });
+ Object.defineProperty(keydownEvent, 'preventDefault', { value: jasmine.createSpy() });
+ element.dispatchEvent(keydownEvent);
+
+ expect(click).toHaveBeenCalledTimes(0);
+
+ tick(buttonConstants.repeatDelay);
+ expect(click).toHaveBeenCalledTimes(1);
+
+ tick(buttonConstants.repeatInterval);
+ expect(click).toHaveBeenCalledTimes(2);
+
+ // Stop repeat with keyup
+ element.dispatchEvent(new KeyboardEvent("keyup", { code: "Space" }));
+ tick(buttonConstants.repeatInterval);
+ expect(click).toHaveBeenCalledTimes(2); // Should not increase
+ }));
+
+ it("should trigger repeat functionality with Enter key", fakeAsync(() => {
+ const click = spyOn(element, "click").and.callThrough();
+
+ // Simulate keydown event
+ element.dispatchEvent(new KeyboardEvent("keydown", { code: "Enter" }));
+
+ expect(click).toHaveBeenCalledTimes(0);
+
+ tick(buttonConstants.repeatDelay);
+ expect(click).toHaveBeenCalledTimes(1);
+
+ tick(buttonConstants.repeatInterval);
+ expect(click).toHaveBeenCalledTimes(2);
+
+ // Stop repeat with keyup
+ element.dispatchEvent(new KeyboardEvent("keyup", { code: "Enter" }));
+ tick(buttonConstants.repeatInterval);
+ expect(click).toHaveBeenCalledTimes(2); // Should not increase
+ }));
+
+ it("should prevent default behavior for Space key", () => {
+ const keydownEvent = new KeyboardEvent("keydown", { code: "Space" });
+ const preventDefaultSpy = spyOn(keydownEvent, 'preventDefault');
+
+ element.dispatchEvent(keydownEvent);
+
+ expect(preventDefaultSpy).toHaveBeenCalled();
+ });
+
+ it("should not prevent default behavior for Enter key", () => {
+ const keydownEvent = new KeyboardEvent("keydown", { code: "Enter" });
+ const preventDefaultSpy = spyOn(keydownEvent, 'preventDefault');
+
+ element.dispatchEvent(keydownEvent);
+
+ expect(preventDefaultSpy).not.toHaveBeenCalled();
+ });
+
+ it("should ignore non-Space/Enter keys", fakeAsync(() => {
+ const click = spyOn(element, "click").and.callThrough();
+
+ // Simulate keydown event with different key
+ element.dispatchEvent(new KeyboardEvent("keydown", { code: "KeyA" }));
+
+ tick(buttonConstants.repeatDelay + buttonConstants.repeatInterval);
+ expect(click).not.toHaveBeenCalled();
+ }));
+ });
+ });
});
});
diff --git a/packages/bits/src/lib/button/button.component.ts b/packages/bits/src/lib/button/button.component.ts
index 49d2b68dc..ae7fcc4f5 100644
--- a/packages/bits/src/lib/button/button.component.ts
+++ b/packages/bits/src/lib/button/button.component.ts
@@ -168,6 +168,12 @@ export class ButtonComponent implements OnInit, OnDestroy, AfterContentChecked {
return this.ariaLabel || this.getAriaLabel();
}
+ @HostBinding("attr.aria-disabled")
+ public get ariaDisabled(): string | null {
+ const hostElement = this.getHostElement();
+ return (hostElement as any).disabled ? "true" : null;
+ }
+
@ViewChild("contentContainer", { static: true, read: ViewContainerRef })
private contentContainer: ViewContainerRef;
@@ -192,6 +198,7 @@ should be set explicitly: `,
public ngOnInit(): void {
this.setupRepeatEvent();
+ this.validateAccessibility();
}
public ngAfterContentChecked(): void {
@@ -245,6 +252,8 @@ should be set explicitly: `,
private setupRepeatEvent() {
const hostElement = this.getHostElement();
+
+ // Mouse events
const mouseUp$ = fromEvent(hostElement, "mouseup").pipe(
takeUntil(this.ngUnsubscribe)
);
@@ -257,23 +266,53 @@ should be set explicitly: `,
filter(() => this.isRepeat)
)
.subscribe(() => {
- const repeatSubscription = timer(
- buttonConstants.repeatDelay,
- buttonConstants.repeatInterval
- )
- .pipe(
- takeUntil(
- merge(mouseUp$, mouseLeave$, this.ngUnsubscribe)
- )
- )
- .subscribe(() => {
- if (hostElement.disabled) {
- repeatSubscription.unsubscribe();
- } else {
- hostElement.click();
- }
- });
+ this.startRepeatTimer(hostElement, merge(mouseUp$, mouseLeave$, this.ngUnsubscribe));
});
+
+ // Keyboard events
+ const keyUp$ = fromEvent(hostElement, "keyup").pipe(
+ takeUntil(this.ngUnsubscribe),
+ filter((event: KeyboardEvent) => event.code === "Space" || event.code === "Enter")
+ );
+ fromEvent(hostElement, "keydown")
+ .pipe(
+ takeUntil(this.ngUnsubscribe),
+ filter((event: KeyboardEvent) => this.isRepeat && (event.code === "Space" || event.code === "Enter"))
+ )
+ .subscribe((event: KeyboardEvent) => {
+ // Prevent default behavior for space to avoid page scrolling
+ if (event.code === "Space") {
+ event.preventDefault();
+ }
+ this.startRepeatTimer(hostElement, merge(keyUp$, this.ngUnsubscribe));
+ });
+ }
+
+ private startRepeatTimer(hostElement: HTMLElement, stopEvents$: any) {
+ const repeatSubscription = timer(
+ buttonConstants.repeatDelay,
+ buttonConstants.repeatInterval
+ )
+ .pipe(takeUntil(stopEvents$))
+ .subscribe(() => {
+ if ((hostElement as any).disabled) {
+ repeatSubscription.unsubscribe();
+ } else {
+ hostElement.click();
+ }
+ });
+ }
+
+ private validateAccessibility(): void {
+ // Check for icon-only buttons without proper aria-label
+ setTimeout(() => {
+ if (this.isEmptyClass && this.icon && !this.ariaLabel) {
+ this.logger.warn(
+ "Icon-only button detected without aria-label. Please provide a meaningful aria-label for accessibility.",
+ this.el.nativeElement
+ );
+ }
+ });
}
private getHostElement() {