From 1463fc804d439c27c10c931cbbc294f19c2da2d4 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Thu, 21 Aug 2025 09:45:49 +0200 Subject: [PATCH 1/4] Remove platform keygen service re-exports (#16081) --- libs/common/src/platform/abstractions/key-generation.service.ts | 2 -- libs/common/src/platform/services/key-generation.service.ts | 2 -- 2 files changed, 4 deletions(-) delete mode 100644 libs/common/src/platform/abstractions/key-generation.service.ts delete mode 100644 libs/common/src/platform/services/key-generation.service.ts diff --git a/libs/common/src/platform/abstractions/key-generation.service.ts b/libs/common/src/platform/abstractions/key-generation.service.ts deleted file mode 100644 index 8a230fb5b86..00000000000 --- a/libs/common/src/platform/abstractions/key-generation.service.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Temporary re-export. This should not be used for new imports */ -export { KeyGenerationService } from "../../key-management/crypto/key-generation/key-generation.service"; diff --git a/libs/common/src/platform/services/key-generation.service.ts b/libs/common/src/platform/services/key-generation.service.ts deleted file mode 100644 index 55d1f96e7df..00000000000 --- a/libs/common/src/platform/services/key-generation.service.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Temporary re-export. This should not be used for new imports */ -export { DefaultKeyGenerationService as KeyGenerationService } from "../../key-management/crypto/key-generation/default-key-generation.service"; From 89bae6bb74917fca420a91b4bec352eaa0de42c0 Mon Sep 17 00:00:00 2001 From: cyprain-okeke <108260115+cyprain-okeke@users.noreply.github.com> Date: Thu, 21 Aug 2025 10:25:48 +0100 Subject: [PATCH 2/4] Remove the VAT field for family plan (#16098) --- .../trial-initiation/trial-billing-step.component.html | 1 + .../trial-initiation/trial-billing-step.component.ts | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.html b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.html index 64a9781b7cf..0c1a4270662 100644 --- a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.html +++ b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.html @@ -51,6 +51,7 @@

{{ "paymentType" | i18n }}

diff --git a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts index e13fac41f75..7e25a422477 100644 --- a/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts +++ b/apps/web/src/app/billing/accounts/trial-initiation/trial-billing-step.component.ts @@ -259,6 +259,15 @@ export class TrialBillingStepComponent implements OnInit, OnDestroy { return planType ? this.applicablePlans.find((plan) => plan.type === planType) : null; } + protected get showTaxIdField(): boolean { + switch (this.organizationInfo.type) { + case ProductTierType.Families: + return false; + default: + return true; + } + } + private getBillingInformationFromTaxInfoComponent(): BillingInformation { return { postalCode: this.taxInfoComponent.getTaxInformation()?.postalCode, From 0daa6913d2f6529f489c5127c16215a1270f8fe5 Mon Sep 17 00:00:00 2001 From: Andreas Coroiu Date: Thu, 21 Aug 2025 14:42:56 +0200 Subject: [PATCH 3/4] feat: add ipc service usage docs (#16000) --- libs/common/src/platform/ipc/ipc.service.ts | 123 +++++++++++++++++++- 1 file changed, 120 insertions(+), 3 deletions(-) diff --git a/libs/common/src/platform/ipc/ipc.service.ts b/libs/common/src/platform/ipc/ipc.service.ts index 2fba4380706..0da13c8f3d8 100644 --- a/libs/common/src/platform/ipc/ipc.service.ts +++ b/libs/common/src/platform/ipc/ipc.service.ts @@ -2,25 +2,136 @@ import { Observable, shareReplay } from "rxjs"; import { IpcClient, IncomingMessage, OutgoingMessage } from "@bitwarden/sdk-internal"; +/** + * Entry point for inter-process communication (IPC). + * + * - {@link IpcService.init} should be called in the initialization phase of the client. + * - This service owns the underlying {@link IpcClient} lifecycle and starts it during initialization. + * + * ## Usage + * + * ### Publish / Subscribe + * There are 2 main ways of sending and receiving messages over IPC in TypeScript: + * + * #### 1. TypeScript only JSON-based messages + * This is the simplest form of IPC, where messages are sent as untyped JSON objects. + * This is useful for simple message passing without the need for Rust code. + * + * ```typescript + * // Send a message + * await ipcService.send(OutgoingMessage.new_json_payload({ my: "data" }, "BrowserBackground", "my-topic")); + * + * // Receive messages + * ipcService.messages$.subscribe((message: IncomingMessage) => { + * if (message.topic === "my-topic") { + * const data = incomingMessage.parse_payload_as_json(); + * console.log("Received message:", data); + * } + * }); + * ``` + * + * #### 2. Rust compatible messages + * If you need to send messages that can also be handled by Rust code you can use typed Rust structs + * together with Rust functions to send and receive messages. For more information on typed structs + * refer to `TypedOutgoingMessage` and `TypedIncomingMessage` in the SDK. + * + * For examples on how to use the RPC framework with Rust see the section below. + * + * ### RPC (Request / Response) + * The RPC functionality is more complex than simple message passing and requires Rust code + * to send and receive calls. For this reason, the service also exposes the underlying + * {@link IpcClient} so it can be passed directly into Rust code. + * + * #### Rust code + * ```rust + * #[wasm_bindgen(js_name = ipcRegisterPingHandler)] + * pub async fn ipc_register_ping_handler(ipc_client: &JsIpcClient) { + * ipc_client + * .client + * // See Rust docs for more information on how to implement a handler + * .register_rpc_handler(PingHandler::new()) + * .await; + * } + * + * #[wasm_bindgen(js_name = ipcRequestPing)] + * pub async fn ipc_request_ping( + * ipc_client: &JsIpcClient, + * destination: Endpoint, + * abort_signal: Option, + * ) -> Result { + * ipc_client + * .client + * .request( + * PingRequest, + * destination, + * abort_signal.map(|c| c.to_cancellation_token()), + * ) + * .await + * } + * ``` + * + * #### TypeScript code + * ```typescript + * import { IpcService } from "@bitwarden/common/platform/ipc"; + * import { IpcClient, ipcRegisterPingHandler, ipcRequestPing } from "@bitwarden/sdk-internal"; + * + * class MyService { + * constructor(private ipcService: IpcService) {} + * + * async init() { + * await ipcRegisterPingHandler(this.ipcService.client); + * } + * + * async ping(destination: Endpoint): Promise { + * return await ipcRequestPing(this.ipcService.client, destination); + * } + * } + */ export abstract class IpcService { private _client?: IpcClient; + + /** + * Access to the underlying {@link IpcClient} for advanced/Rust RPC usage. + * + * @throws If the service has not been initialized. + */ get client(): IpcClient { if (!this._client) { - throw new Error("IpcService not initialized"); + throw new Error("IpcService not initialized. Call init() first."); } return this._client; } private _messages$?: Observable; - protected get messages$(): Observable { + + /** + * Hot stream of {@link IncomingMessage} from the IPC layer. + * + * @remarks + * - Uses `shareReplay({ bufferSize: 0, refCount: true })`, so no events are replayed to late subscribers. + * Subscribe early if you must not miss messages. + * + * @throws If the service has not been initialized. + */ + get messages$(): Observable { if (!this._messages$) { - throw new Error("IpcService not initialized"); + throw new Error("IpcService not initialized. Call init() first."); } return this._messages$; } + /** + * Initializes the service and starts the IPC client. + */ abstract init(): Promise; + /** + * Wires the provided {@link IpcClient}, starts it, and sets up the message stream. + * + * - Starts the client via `client.start()`. + * - Subscribes to the client's receive loop and exposes it through {@link messages$}. + * - Implementations may override `init` but should call this helper exactly once. + */ protected async initWithClient(client: IpcClient): Promise { this._client = client; await this._client.start(); @@ -47,6 +158,12 @@ export abstract class IpcService { }).pipe(shareReplay({ bufferSize: 0, refCount: true })); } + /** + * Sends an {@link OutgoingMessage} over IPC. + * + * @param message The message to send. + * @throws If the service is not initialized or the underlying client fails to send. + */ async send(message: OutgoingMessage) { await this.client.send(message); } From b0f46004ff0ef30323ff4a181178eae752543020 Mon Sep 17 00:00:00 2001 From: Bryan Cunningham Date: Thu, 21 Aug 2025 09:14:08 -0400 Subject: [PATCH 4/4] [CL-796] unrevert aria disabled buttons (#15924) * Use aria-disabled for button disabled state * remove import from testing story * use aria-disabled attr on bitLink button * remove unnecessary story attrs * remove disabled attr if on button element * create caprture click util * use caprture click util and fix tests * fix lint errors * fix event type * combine click capture and attr modification * fix lint error. Commit spec changes left out of last commit in error * inject element ref * move aria-disabled styles to common * move disabled logic into util * fix broken async actions stories * fix broken tests asserting disabled attr * have test check for string true vlalue * fix Signal type * fix form-field story import * remove injector left in error * aria-disable icon buttons * update form component css selector to look for aria-disabled buttons * use correct types. pass nativeElement directly * add JSDoc comment for util function * WIP * WIP * inject service in directive * remove console log * remove disabled attr left in error * update comments * remove unnecessary logic * remove :disabled psuedo selector as its apparently not needed * fix event type * coerce disabled attr to boolean * remove duplicate style concat left by conflict resolution * add back buttonStyles default * move reactive logic back to helper * add test to ensure menu button doesn't open when trigger is disabled * remove menu toggle to fix tests * remove disabled menu story * Fix usage of bitLink in verify email component * Update varaible name * no longer pass destroyRef --- .../vault-generator-dialog.component.spec.ts | 18 +++----- .../auth/settings/verify-email.component.html | 3 +- .../web-generator-dialog.component.spec.ts | 18 +++----- .../src/a11y/aria-disable.directive.ts | 12 ++++++ .../aria-disabled-click-capture.service.ts | 30 ++++++++++++++ libs/components/src/a11y/index.ts | 2 + .../src/button/button.component.spec.ts | 14 ++++--- .../components/src/button/button.component.ts | 41 +++++++++++++------ .../src/form-field/form-field.component.html | 2 +- .../src/icon-button/icon-button.component.ts | 29 ++++++++++--- libs/components/src/link/link.directive.ts | 20 ++++++++- .../src/menu/menu.component.spec.ts | 8 ++++ libs/components/src/toast/toast.stories.ts | 1 + .../src/utils/aria-disable-element.ts | 19 +++++++++ libs/components/src/utils/index.ts | 1 + 15 files changed, 165 insertions(+), 53 deletions(-) create mode 100644 libs/components/src/a11y/aria-disable.directive.ts create mode 100644 libs/components/src/a11y/aria-disabled-click-capture.service.ts create mode 100644 libs/components/src/utils/aria-disable-element.ts diff --git a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts index b5d35e2005e..b65138dac3a 100644 --- a/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts +++ b/apps/browser/src/vault/popup/components/vault-v2/vault-generator-dialog/vault-generator-dialog.component.spec.ts @@ -76,10 +76,8 @@ describe("VaultGeneratorDialogComponent", () => { component.onValueGenerated("test-password"); fixture.detectChanges(); - const button = fixture.debugElement.query( - By.css("[data-testid='select-button']"), - ).nativeElement; - expect(button.disabled).toBe(false); + const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); + expect(button.attributes["aria-disabled"]).toBe(undefined); }); it("should disable the button if no value has been generated", () => { @@ -90,10 +88,8 @@ describe("VaultGeneratorDialogComponent", () => { generator.algorithmSelected.emit({ useGeneratedValue: "Use Password" } as any); fixture.detectChanges(); - const button = fixture.debugElement.query( - By.css("[data-testid='select-button']"), - ).nativeElement; - expect(button.disabled).toBe(true); + const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); + expect(button.attributes["aria-disabled"]).toBe("true"); }); it("should disable the button if no algorithm is selected", () => { @@ -104,10 +100,8 @@ describe("VaultGeneratorDialogComponent", () => { generator.valueGenerated.emit("test-password"); fixture.detectChanges(); - const button = fixture.debugElement.query( - By.css("[data-testid='select-button']"), - ).nativeElement; - expect(button.disabled).toBe(true); + const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); + expect(button.attributes["aria-disabled"]).toBe("true"); }); it("should update button text when algorithm is selected", () => { diff --git a/apps/web/src/app/auth/settings/verify-email.component.html b/apps/web/src/app/auth/settings/verify-email.component.html index a691c30695c..42a546fc281 100644 --- a/apps/web/src/app/auth/settings/verify-email.component.html +++ b/apps/web/src/app/auth/settings/verify-email.component.html @@ -4,10 +4,9 @@ id="sendBtn" bitLink linkType="secondary" - bitButton type="button" buttonType="unstyled" - [bitAction]="send" + (click)="send()" > {{ "sendEmail" | i18n }} diff --git a/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts index 085a3d0d4b0..afb32738901 100644 --- a/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts +++ b/apps/web/src/app/vault/components/web-generator-dialog/web-generator-dialog.component.spec.ts @@ -70,10 +70,8 @@ describe("WebVaultGeneratorDialogComponent", () => { generator.valueGenerated.emit("test-password"); fixture.detectChanges(); - const button = fixture.debugElement.query( - By.css("[data-testid='select-button']"), - ).nativeElement; - expect(button.disabled).toBe(false); + const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); + expect(button.attributes["aria-disabled"]).toBe(undefined); }); it("should disable the button if no value has been generated", () => { @@ -84,10 +82,8 @@ describe("WebVaultGeneratorDialogComponent", () => { generator.algorithmSelected.emit({ useGeneratedValue: "Use Password" } as any); fixture.detectChanges(); - const button = fixture.debugElement.query( - By.css("[data-testid='select-button']"), - ).nativeElement; - expect(button.disabled).toBe(true); + const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); + expect(button.attributes["aria-disabled"]).toBe("true"); }); it("should disable the button if no algorithm is selected", () => { @@ -98,10 +94,8 @@ describe("WebVaultGeneratorDialogComponent", () => { generator.valueGenerated.emit("test-password"); fixture.detectChanges(); - const button = fixture.debugElement.query( - By.css("[data-testid='select-button']"), - ).nativeElement; - expect(button.disabled).toBe(true); + const button = fixture.debugElement.query(By.css("[data-testid='select-button']")); + expect(button.attributes["aria-disabled"]).toBe("true"); }); it("should close with selected value when confirmed", () => { diff --git a/libs/components/src/a11y/aria-disable.directive.ts b/libs/components/src/a11y/aria-disable.directive.ts new file mode 100644 index 00000000000..8236e178994 --- /dev/null +++ b/libs/components/src/a11y/aria-disable.directive.ts @@ -0,0 +1,12 @@ +import { Directive, inject } from "@angular/core"; + +import { AriaDisabledClickCaptureService } from "./aria-disabled-click-capture.service"; + +@Directive({ + host: { + "[attr.bit-aria-disable]": "true", + }, +}) +export class AriaDisableDirective { + protected ariaDisabledClickCaptureService = inject(AriaDisabledClickCaptureService); +} diff --git a/libs/components/src/a11y/aria-disabled-click-capture.service.ts b/libs/components/src/a11y/aria-disabled-click-capture.service.ts new file mode 100644 index 00000000000..d828d15c873 --- /dev/null +++ b/libs/components/src/a11y/aria-disabled-click-capture.service.ts @@ -0,0 +1,30 @@ +import { DOCUMENT } from "@angular/common"; +import { Injectable, Inject, NgZone, OnDestroy } from "@angular/core"; + +@Injectable({ providedIn: "root" }) +export class AriaDisabledClickCaptureService implements OnDestroy { + private listener!: (e: MouseEvent | KeyboardEvent) => void; + + constructor( + @Inject(DOCUMENT) private document: Document, + private ngZone: NgZone, + ) { + this.ngZone.runOutsideAngular(() => { + this.listener = (e: MouseEvent | KeyboardEvent) => { + const btn = (e.target as HTMLElement).closest( + '[aria-disabled="true"][bit-aria-disable="true"]', + ); + if (btn) { + e.stopPropagation(); + e.preventDefault(); + return false; + } + }; + this.document.addEventListener("click", this.listener, /* capture */ true); + }); + } + + ngOnDestroy() { + this.document.removeEventListener("click", this.listener, true); + } +} diff --git a/libs/components/src/a11y/index.ts b/libs/components/src/a11y/index.ts index 6090fb65d4e..2a723f14c93 100644 --- a/libs/components/src/a11y/index.ts +++ b/libs/components/src/a11y/index.ts @@ -1 +1,3 @@ export * from "./a11y-title.directive"; +export * from "./aria-disabled-click-capture.service"; +export * from "./aria-disable.directive"; diff --git a/libs/components/src/button/button.component.spec.ts b/libs/components/src/button/button.component.spec.ts index 6ddbc172803..1651b6cf12a 100644 --- a/libs/components/src/button/button.component.spec.ts +++ b/libs/components/src/button/button.component.spec.ts @@ -34,23 +34,25 @@ describe("Button", () => { expect(buttonDebugElement.nativeElement.disabled).toBeFalsy(); }); - it("should be disabled when disabled is true", () => { + it("should be aria-disabled and not html attribute disabled when disabled is true", () => { testAppComponent.disabled = true; fixture.detectChanges(); - - expect(buttonDebugElement.nativeElement.disabled).toBeTruthy(); + expect(buttonDebugElement.attributes["aria-disabled"]).toBe("true"); + expect(buttonDebugElement.nativeElement.disabled).toBeFalsy(); // Anchor tags cannot be disabled. }); - it("should be disabled when attribute disabled is true", () => { - expect(disabledButtonDebugElement.nativeElement.disabled).toBeTruthy(); + it("should be aria-disabled not html attribute disabled when attribute disabled is true", () => { + fixture.detectChanges(); + expect(disabledButtonDebugElement.attributes["aria-disabled"]).toBe("true"); + expect(disabledButtonDebugElement.nativeElement.disabled).toBeFalsy(); }); it("should be disabled when loading is true", () => { testAppComponent.loading = true; fixture.detectChanges(); - expect(buttonDebugElement.nativeElement.disabled).toBeTruthy(); + expect(buttonDebugElement.attributes["aria-disabled"]).toBe("true"); }); }); diff --git a/libs/components/src/button/button.component.ts b/libs/components/src/button/button.component.ts index 635c269bd0f..1dce792c963 100644 --- a/libs/components/src/button/button.component.ts +++ b/libs/components/src/button/button.component.ts @@ -1,9 +1,20 @@ import { NgClass } from "@angular/common"; -import { input, HostBinding, Component, model, computed, booleanAttribute } from "@angular/core"; +import { + input, + HostBinding, + Component, + model, + computed, + booleanAttribute, + inject, + ElementRef, +} from "@angular/core"; import { toObservable, toSignal } from "@angular/core/rxjs-interop"; import { debounce, interval } from "rxjs"; +import { AriaDisableDirective } from "../a11y"; import { ButtonLikeAbstraction, ButtonType, ButtonSize } from "../shared/button-like.abstraction"; +import { ariaDisableElement } from "../utils"; const focusRing = [ "focus-visible:tw-ring-2", @@ -50,9 +61,7 @@ const buttonStyles: Record = { templateUrl: "button.component.html", providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }], imports: [NgClass], - host: { - "[attr.disabled]": "disabledAttr()", - }, + hostDirectives: [AriaDisableDirective], }) export class ButtonComponent implements ButtonLikeAbstraction { @HostBinding("class") get classList() { @@ -72,14 +81,15 @@ export class ButtonComponent implements ButtonLikeAbstraction { .concat( this.showDisabledStyles() || this.disabled() ? [ - "disabled:tw-bg-secondary-300", - "disabled:hover:tw-bg-secondary-300", - "disabled:tw-border-secondary-300", - "disabled:hover:tw-border-secondary-300", - "disabled:!tw-text-muted", - "disabled:hover:!tw-text-muted", - "disabled:tw-cursor-not-allowed", - "disabled:hover:tw-no-underline", + "aria-disabled:!tw-bg-secondary-300", + "hover:tw-bg-secondary-300", + "aria-disabled:tw-border-secondary-300", + "hover:tw-border-secondary-300", + "aria-disabled:!tw-text-muted", + "hover:!tw-text-muted", + "aria-disabled:tw-cursor-not-allowed", + "hover:tw-no-underline", + "aria-disabled:tw-pointer-events-none", ] : [], ) @@ -88,7 +98,7 @@ export class ButtonComponent implements ButtonLikeAbstraction { protected disabledAttr = computed(() => { const disabled = this.disabled() != null && this.disabled() !== false; - return disabled || this.loading() ? true : null; + return disabled || this.loading(); }); /** @@ -128,4 +138,9 @@ export class ButtonComponent implements ButtonLikeAbstraction { ); disabled = model(false); + private el = inject(ElementRef); + + constructor() { + ariaDisableElement(this.el.nativeElement, this.disabledAttr); + } } diff --git a/libs/components/src/form-field/form-field.component.html b/libs/components/src/form-field/form-field.component.html index b40e16d4cd4..ae3bad40698 100644 --- a/libs/components/src/form-field/form-field.component.html +++ b/libs/components/src/form-field/form-field.component.html @@ -46,7 +46,7 @@
= { ], imports: [NgClass], host: { - "[attr.disabled]": "disabledAttr()", /** * When the `bitIconButton` input is dynamic from a consumer, Angular doesn't put the * `bitIconButton` attribute into the DOM. We use the attribute as a css selector in @@ -87,6 +97,7 @@ const sizes: Record = { */ "[attr.bitIconButton]": "icon()", }, + hostDirectives: [AriaDisableDirective], }) export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement { readonly icon = model.required({ alias: "bitIconButton" }); @@ -118,7 +129,7 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE .concat(sizes[this.size()]) .concat( this.showDisabledStyles() || this.disabled() - ? ["disabled:tw-opacity-60", "disabled:hover:!tw-bg-transparent"] + ? ["aria-disabled:tw-opacity-60", "aria-disabled:hover:!tw-bg-transparent"] : [], ); } @@ -129,7 +140,7 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE protected disabledAttr = computed(() => { const disabled = this.disabled() != null && this.disabled() !== false; - return disabled || this.loading() ? true : null; + return disabled || this.loading(); }); /** @@ -168,8 +179,14 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE return this.elementRef.nativeElement; } - constructor(private elementRef: ElementRef) { - const originalTitle = this.elementRef.nativeElement.getAttribute("title"); + private elementRef = inject(ElementRef); + + constructor() { + const element = this.elementRef.nativeElement; + + ariaDisableElement(element, this.disabledAttr); + + const originalTitle = element.getAttribute("title"); effect(() => { setA11yTitleAndAriaLabel({ diff --git a/libs/components/src/link/link.directive.ts b/libs/components/src/link/link.directive.ts index f2eb44bc3a4..7c93b185a79 100644 --- a/libs/components/src/link/link.directive.ts +++ b/libs/components/src/link/link.directive.ts @@ -1,4 +1,7 @@ -import { input, HostBinding, Directive } from "@angular/core"; +import { input, HostBinding, Directive, inject, ElementRef, booleanAttribute } from "@angular/core"; + +import { AriaDisableDirective } from "../a11y"; +import { ariaDisableElement } from "../utils"; export type LinkType = "primary" | "secondary" | "contrast" | "light"; @@ -58,6 +61,11 @@ const commonStyles = [ "before:tw-transition", "focus-visible:before:tw-ring-2", "focus-visible:tw-z-10", + "aria-disabled:tw-no-underline", + "aria-disabled:tw-pointer-events-none", + "aria-disabled:!tw-text-secondary-300", + "aria-disabled:hover:!tw-text-secondary-300", + "aria-disabled:hover:tw-no-underline", ]; @Directive() @@ -86,11 +94,21 @@ export class AnchorLinkDirective extends LinkDirective { @Directive({ selector: "button[bitLink]", + hostDirectives: [AriaDisableDirective], }) export class ButtonLinkDirective extends LinkDirective { + private el = inject(ElementRef); + + disabled = input(false, { transform: booleanAttribute }); + @HostBinding("class") get classList() { return ["before:-tw-inset-y-[0.25rem]"] .concat(commonStyles) .concat(linkStyles[this.linkType()] ?? []); } + + constructor() { + super(); + ariaDisableElement(this.el.nativeElement, this.disabled); + } } diff --git a/libs/components/src/menu/menu.component.spec.ts b/libs/components/src/menu/menu.component.spec.ts index c6a54f1afae..3153fd4eb37 100644 --- a/libs/components/src/menu/menu.component.spec.ts +++ b/libs/components/src/menu/menu.component.spec.ts @@ -58,6 +58,14 @@ describe("Menu", () => { expect(getBitMenuPanel()).toBeFalsy(); }); + + it("should not open when the trigger button is disabled", () => { + const buttonDebugElement = fixture.debugElement.query(By.directive(MenuTriggerForDirective)); + buttonDebugElement.nativeElement.setAttribute("disabled", "true"); + (buttonDebugElement.nativeElement as HTMLButtonElement).click(); + + expect(getBitMenuPanel()).toBeFalsy(); + }); }); @Component({ diff --git a/libs/components/src/toast/toast.stories.ts b/libs/components/src/toast/toast.stories.ts index bd2c59a191b..dee31f1d6ac 100644 --- a/libs/components/src/toast/toast.stories.ts +++ b/libs/components/src/toast/toast.stories.ts @@ -20,6 +20,7 @@ const toastServiceExampleTemplate = ` @Component({ selector: "toast-service-example", template: toastServiceExampleTemplate, + imports: [ButtonModule], }) export class ToastServiceExampleComponent { @Input() diff --git a/libs/components/src/utils/aria-disable-element.ts b/libs/components/src/utils/aria-disable-element.ts new file mode 100644 index 00000000000..0f7fb4ca205 --- /dev/null +++ b/libs/components/src/utils/aria-disable-element.ts @@ -0,0 +1,19 @@ +import { Signal } from "@angular/core"; +import { toObservable, takeUntilDestroyed } from "@angular/core/rxjs-interop"; + +/** + * a11y helper util used to `aria-disable` elements as opposed to using the HTML `disabled` attr. + * - Removes HTML `disabled` attr and replaces it with `aria-disabled="true"` + */ +export function ariaDisableElement(el: HTMLElement, disabled: Signal) { + toObservable(disabled) + .pipe(takeUntilDestroyed()) + .subscribe((isDisabled) => { + if (isDisabled) { + el.removeAttribute("disabled"); + el.setAttribute("aria-disabled", "true"); + } else { + el.removeAttribute("aria-disabled"); + } + }); +} diff --git a/libs/components/src/utils/index.ts b/libs/components/src/utils/index.ts index afadd6b3b41..91fa71cf0e0 100644 --- a/libs/components/src/utils/index.ts +++ b/libs/components/src/utils/index.ts @@ -1,2 +1,3 @@ +export * from "./aria-disable-element"; export * from "./function-to-observable"; export * from "./i18n-mock.service";