diff --git a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts index 6551c84a4e29..6918bedb9bfa 100644 --- a/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts +++ b/apps/browser/src/vault/popup/components/at-risk-passwords/at-risk-passwords.component.ts @@ -104,7 +104,7 @@ export class AtRiskPasswordsComponent implements OnInit { * The UI utilize a bitBadge which does not support async actions (like bitButton does). * @protected */ - protected launchingCipher = signal(null); + protected readonly launchingCipher = signal(null); private activeUserData$ = this.accountService.activeAccount$.pipe( filterOutNullish(), diff --git a/libs/components/src/button/button.component.ts b/libs/components/src/button/button.component.ts index 350d493f832c..6ef5309b0185 100644 --- a/libs/components/src/button/button.component.ts +++ b/libs/components/src/button/button.component.ts @@ -92,7 +92,6 @@ export class ButtonComponent implements ButtonLikeAbstraction { "hover:!tw-text-muted", "aria-disabled:tw-cursor-not-allowed", "hover:tw-no-underline", - "aria-disabled:tw-pointer-events-none", ] : [], ) diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index f1edee7c0897..9887c0bde8b0 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -17,6 +17,7 @@ import { setA11yTitleAndAriaLabel } from "../a11y/set-a11y-title-and-aria-label" import { ButtonLikeAbstraction } from "../shared/button-like.abstraction"; import { FocusableElement } from "../shared/focusable-element"; import { SpinnerComponent } from "../spinner"; +import { TooltipDirective } from "../tooltip"; import { ariaDisableElement } from "../utils"; export type IconButtonType = "primary" | "danger" | "contrast" | "main" | "muted" | "nav-contrast"; @@ -100,7 +101,10 @@ const sizes: Record = { */ "[attr.bitIconButton]": "icon()", }, - hostDirectives: [AriaDisableDirective], + hostDirectives: [ + AriaDisableDirective, + { directive: TooltipDirective, inputs: ["tooltipPosition"] }, + ], }) export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement { readonly icon = model.required({ alias: "bitIconButton" }); @@ -109,6 +113,9 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE readonly size = model("default"); + private elementRef = inject(ElementRef); + private tooltip = inject(TooltipDirective, { host: true, optional: true }); + /** * label input will be used to set the `aria-label` attributes on the button. * This is for accessibility purposes, as it provides a text alternative for the icon button. @@ -186,8 +193,6 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE return this.elementRef.nativeElement; } - private elementRef = inject(ElementRef); - constructor() { const element = this.elementRef.nativeElement; @@ -198,9 +203,15 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE effect(() => { setA11yTitleAndAriaLabel({ element: this.elementRef.nativeElement, - title: originalTitle ?? this.label(), + title: undefined, label: this.label(), }); + + const tooltipContent: string = originalTitle || this.label(); + + if (tooltipContent) { + this.tooltip?.tooltipContent.set(tooltipContent); + } }); } } diff --git a/libs/components/src/tooltip/tooltip.component.html b/libs/components/src/tooltip/tooltip.component.html index 4d354fc27653..ce9f1ceeffea 100644 --- a/libs/components/src/tooltip/tooltip.component.html +++ b/libs/components/src/tooltip/tooltip.component.html @@ -1,9 +1,11 @@ -
- +} diff --git a/libs/components/src/tooltip/tooltip.component.ts b/libs/components/src/tooltip/tooltip.component.ts index 34c670150043..79e2dfd79739 100644 --- a/libs/components/src/tooltip/tooltip.component.ts +++ b/libs/components/src/tooltip/tooltip.component.ts @@ -15,6 +15,7 @@ type TooltipData = { content: Signal; isVisible: Signal; tooltipPosition: Signal; + id: Signal; }; export const TOOLTIP_DATA = new InjectionToken("TOOLTIP_DATA"); diff --git a/libs/components/src/tooltip/tooltip.directive.ts b/libs/components/src/tooltip/tooltip.directive.ts index b2c1621d7101..bcf9fc5e1744 100644 --- a/libs/components/src/tooltip/tooltip.directive.ts +++ b/libs/components/src/tooltip/tooltip.directive.ts @@ -8,8 +8,9 @@ import { ElementRef, Injector, input, - effect, signal, + model, + computed, } from "@angular/core"; import { TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions"; @@ -26,30 +27,39 @@ import { TooltipComponent, TOOLTIP_DATA } from "./tooltip.component"; "(mouseleave)": "hideTooltip()", "(focus)": "showTooltip()", "(blur)": "hideTooltip()", + "[attr.aria-describedby]": "resolvedDescribedByIds()", }, }) export class TooltipDirective implements OnInit { + private static nextId = 0; /** * The value of this input is forwarded to the tooltip.component to render */ - readonly bitTooltip = input.required(); + readonly tooltipContent = model("", { alias: "bitTooltip" }); /** * The value of this input is forwarded to the tooltip.component to set its position explicitly. * @default "above-center" */ readonly tooltipPosition = input("above-center"); + /** + * Input so the consumer can choose to add the tooltip id to the aria-describedby attribute of the host element. + */ + readonly addTooltipToDescribedby = input(false); + private readonly isVisible = signal(false); private overlayRef: OverlayRef | undefined; - private elementRef = inject(ElementRef); + private elementRef = inject>(ElementRef); private overlay = inject(Overlay); private viewContainerRef = inject(ViewContainerRef); - private injector = inject(Injector); private positionStrategy = this.overlay .position() .flexibleConnectedTo(this.elementRef) .withFlexibleDimensions(false) .withPush(true); + private tooltipId = `bit-tooltip-${TooltipDirective.nextId++}`; + private currentDescribedByIds = + this.elementRef.nativeElement.getAttribute("aria-describedby") || null; private tooltipPortal = new ComponentPortal( TooltipComponent, @@ -59,23 +69,50 @@ export class TooltipDirective implements OnInit { { provide: TOOLTIP_DATA, useValue: { - content: this.bitTooltip, + content: this.tooltipContent, isVisible: this.isVisible, tooltipPosition: this.tooltipPosition, + id: signal(this.tooltipId), }, }, ], }), ); + private destroyTooltip = () => { + this.overlayRef?.dispose(); + this.overlayRef = undefined; + this.isVisible.set(false); + }; + private showTooltip = () => { + if (!this.overlayRef) { + this.overlayRef = this.overlay.create({ + ...this.defaultPopoverConfig, + positionStrategy: this.positionStrategy, + }); + + this.overlayRef.attach(this.tooltipPortal); + } this.isVisible.set(true); }; private hideTooltip = () => { - this.isVisible.set(false); + this.destroyTooltip(); }; + private readonly resolvedDescribedByIds = computed(() => { + if (this.addTooltipToDescribedby()) { + if (this.currentDescribedByIds) { + return `${this.currentDescribedByIds || ""} ${this.tooltipId}`; + } else { + return this.tooltipId; + } + } else { + return this.currentDescribedByIds; + } + }); + private computePositions(tooltipPosition: TooltipPositionIdentifier) { const chosenPosition = tooltipPositions.find((position) => position.id === tooltipPosition); @@ -91,20 +128,5 @@ export class TooltipDirective implements OnInit { ngOnInit() { this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition())); - - this.overlayRef = this.overlay.create({ - ...this.defaultPopoverConfig, - positionStrategy: this.positionStrategy, - }); - - this.overlayRef.attach(this.tooltipPortal); - - effect( - () => { - this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition())); - this.overlayRef?.updatePosition(); - }, - { injector: this.injector }, - ); } } diff --git a/libs/components/src/tooltip/tooltip.mdx b/libs/components/src/tooltip/tooltip.mdx index 4b6f10d97f87..13e159c98ebb 100644 --- a/libs/components/src/tooltip/tooltip.mdx +++ b/libs/components/src/tooltip/tooltip.mdx @@ -11,7 +11,20 @@ import { TooltipDirective } from "@bitwarden/components"; <Description /> -NOTE: The `TooltipComponent` can't be used on its own. It must be applied via the `TooltipDirective` +### Tooltip usage + +The `TooltipComponent` can't be used on its own. It must be applied via the `TooltipDirective`. + +The `IconButtonComponent` will automatically apply a tooltip based on the component's `label` input. + +#### Adding the tooltip to the host element's `aria-describedby` list + +The `addTooltipToDescribedby="true"` model input can be used to add the tooltip id to the list of +the host element's `aria-describedby` element IDs. + +NOTE: This behavior is not always necessary and could be redundant if the host element's aria +attributes already convey the same message as the tooltip. Use only when the tooltip is extra, +non-essential contextual information. <Primary /> <Controls /> @@ -29,3 +42,7 @@ NOTE: The `TooltipComponent` can't be used on its own. It must be applied via th ### On disabled element <Canvas of={stories.OnDisabledButton} /> + +### On a Button + +<Canvas of={stories.OnNonIconButton} /> diff --git a/libs/components/src/tooltip/tooltip.spec.ts b/libs/components/src/tooltip/tooltip.spec.ts index b6a49acbc771..a88424de3bb4 100644 --- a/libs/components/src/tooltip/tooltip.spec.ts +++ b/libs/components/src/tooltip/tooltip.spec.ts @@ -59,7 +59,14 @@ describe("TooltipDirective (visibility only)", () => { }; const overlayRefStub: OverlayRefStub = { - attach: jest.fn(() => ({})), + attach: jest.fn(() => ({ + changeDetectorRef: { detectChanges: jest.fn() }, + location: { + nativeElement: { + querySelector: jest.fn().mockReturnValue({ id: "tip-123" }), + }, + }, + })), updatePosition: jest.fn(), }; diff --git a/libs/components/src/tooltip/tooltip.stories.ts b/libs/components/src/tooltip/tooltip.stories.ts index 8ea3f52f913d..73dad5801f31 100644 --- a/libs/components/src/tooltip/tooltip.stories.ts +++ b/libs/components/src/tooltip/tooltip.stories.ts @@ -72,7 +72,6 @@ type Story = StoryObj<TooltipDirective>; export const Default: Story = { args: { - bitTooltip: "This is a tooltip", tooltipPosition: "above-center", }, render: (args) => ({ @@ -81,6 +80,7 @@ export const Default: Story = { <div class="tw-p-4"> <button bitIconButton="bwi-ellipsis-v" + label="Your tooltip content here" ${formatArgsForCodeSnippet<TooltipDirective>(args)} > Button label here @@ -98,26 +98,29 @@ export const Default: Story = { export const AllPositions: Story = { render: () => ({ + parameters: { + chromatic: { disableSnapshot: true }, + }, template: ` <div class="tw-p-16 tw-grid tw-grid-cols-2 tw-gap-8 tw-place-items-center"> <button bitIconButton="bwi-angle-up" - bitTooltip="Top tooltip" + label="Top tooltip" tooltipPosition="above-center" ></button> <button bitIconButton="bwi-angle-right" - bitTooltip="Right tooltip" + label="Right tooltip" tooltipPosition="right-center" ></button> <button bitIconButton="bwi-angle-left" - bitTooltip="Left tooltip" + label="Left tooltip" tooltipPosition="left-center" ></button> <button bitIconButton="bwi-angle-down" - bitTooltip="Bottom tooltip" + label="Bottom tooltip" tooltipPosition="below-center" ></button> </div> @@ -127,11 +130,14 @@ export const AllPositions: Story = { export const LongContent: Story = { render: () => ({ + parameters: { + chromatic: { disableSnapshot: true }, + }, template: ` <div class="tw-p-16 tw-flex tw-items-center tw-justify-center"> <button bitIconButton="bwi-ellipsis-v" - bitTooltip="This is a very long tooltip that will wrap to multiple lines to demonstrate how the tooltip handles long content. This is not recommended for usability." + label="This is a very long tooltip that will wrap to multiple lines to demonstrate how the tooltip handles long content. This is not recommended for usability." ></button> </div> `, @@ -140,14 +146,34 @@ export const LongContent: Story = { export const OnDisabledButton: Story = { render: () => ({ + parameters: { + chromatic: { disableSnapshot: true }, + }, template: ` <div class="tw-p-16 tw-flex tw-items-center tw-justify-center"> <button bitIconButton="bwi-ellipsis-v" - bitTooltip="Tooltip on disabled button" + label="Tooltip on disabled button" [disabled]="true" ></button> </div> `, }), }; + +export const OnNonIconButton: Story = { + render: () => ({ + parameters: { + chromatic: { disableSnapshot: true }, + }, + template: ` + <div class="tw-p-16 tw-flex tw-items-center tw-justify-center"> + <button + bitButton + addTooltipToDescribedby="true" + bitTooltip="Some additional tooltip text to describe the button" + >Button label</button> + </div> + `, + }), +}; diff --git a/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.spec.ts b/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.spec.ts index 941b37409526..4e3899407d2f 100644 --- a/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.spec.ts +++ b/libs/vault/src/cipher-form/components/attachments/delete-attachment/delete-attachment.component.spec.ts @@ -68,7 +68,7 @@ describe("DeleteAttachmentComponent", () => { it("renders delete button", () => { const deleteButton = fixture.debugElement.query(By.css("button")); - expect(deleteButton.attributes["title"]).toBe("deleteAttachmentName"); + expect(deleteButton.attributes["aria-label"]).toBe("deleteAttachmentName"); }); it("does not delete when the user cancels the dialog", async () => { diff --git a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts index 0d7f36639675..2d06f5dcc290 100644 --- a/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts +++ b/libs/vault/src/cipher-form/components/autofill-options/uri-option.component.spec.ts @@ -149,13 +149,17 @@ describe("UriOptionComponent", () => { expect(getMatchDetectionSelect()).not.toBeNull(); }); - it("should update the match detection button title when the toggle is clicked", () => { + it("should update the match detection button aria-label when the toggle is clicked", () => { component.writeValue({ uri: "https://example.com", matchDetection: UriMatchStrategy.Exact }); fixture.detectChanges(); - expect(getToggleMatchDetectionBtn().title).toBe("showMatchDetection https://example.com"); + expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe( + "showMatchDetection https://example.com", + ); getToggleMatchDetectionBtn().click(); fixture.detectChanges(); - expect(getToggleMatchDetectionBtn().title).toBe("hideMatchDetection https://example.com"); + expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe( + "hideMatchDetection https://example.com", + ); }); }); diff --git a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts index 8ba7b29a5266..ec5a9ce96fda 100644 --- a/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts +++ b/libs/vault/src/components/download-attachment/download-attachment.component.spec.ts @@ -108,7 +108,7 @@ describe("DownloadAttachmentComponent", () => { it("renders delete button", () => { const deleteButton = fixture.debugElement.query(By.css("button")); - expect(deleteButton.attributes["title"]).toBe("downloadAttachmentName"); + expect(deleteButton.attributes["aria-label"]).toBe("downloadAttachmentName"); }); describe("download attachment", () => {