1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[CL-879] use tooltip on icon button (#16576)

* Add tooltip to icon button to display label

* remove legacy cdr variable

* create overlay on focus or hover

* attach describdedby ids

* fix type errors

* remove aria-describedby when not necessary

* fix failing tests

* implement Claude feedback

* fixing broken specs

* remove host attr binding

* Simplify directive aria logic

* Move id to statis number

* do not render empty tooltip

* pass id to tooltip component

* remove pointer-events none to allow tooltip on normal buttons

* exclude some tooltip stories

* change describedby input name

* add story with tooltip on regular button

* enhanced tooltip docs

* set model directly

* change model to input
This commit is contained in:
Bryan Cunningham
2025-10-29 09:49:16 -04:00
committed by GitHub
parent 460d66d624
commit 5b815c4ae4
11 changed files with 137 additions and 48 deletions

View File

@@ -92,7 +92,6 @@ export class ButtonComponent implements ButtonLikeAbstraction {
"hover:!tw-text-muted", "hover:!tw-text-muted",
"aria-disabled:tw-cursor-not-allowed", "aria-disabled:tw-cursor-not-allowed",
"hover:tw-no-underline", "hover:tw-no-underline",
"aria-disabled:tw-pointer-events-none",
] ]
: [], : [],
) )

View File

@@ -17,6 +17,7 @@ import { setA11yTitleAndAriaLabel } from "../a11y/set-a11y-title-and-aria-label"
import { ButtonLikeAbstraction } from "../shared/button-like.abstraction"; import { ButtonLikeAbstraction } from "../shared/button-like.abstraction";
import { FocusableElement } from "../shared/focusable-element"; import { FocusableElement } from "../shared/focusable-element";
import { SpinnerComponent } from "../spinner"; import { SpinnerComponent } from "../spinner";
import { TooltipDirective } from "../tooltip";
import { ariaDisableElement } from "../utils"; import { ariaDisableElement } from "../utils";
export type IconButtonType = "primary" | "danger" | "contrast" | "main" | "muted" | "nav-contrast"; export type IconButtonType = "primary" | "danger" | "contrast" | "main" | "muted" | "nav-contrast";
@@ -100,7 +101,10 @@ const sizes: Record<IconButtonSize, string[]> = {
*/ */
"[attr.bitIconButton]": "icon()", "[attr.bitIconButton]": "icon()",
}, },
hostDirectives: [AriaDisableDirective], hostDirectives: [
AriaDisableDirective,
{ directive: TooltipDirective, inputs: ["tooltipPosition"] },
],
}) })
export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement { export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement {
readonly icon = model.required<string>({ alias: "bitIconButton" }); readonly icon = model.required<string>({ alias: "bitIconButton" });
@@ -109,6 +113,9 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
readonly size = model<IconButtonSize>("default"); readonly size = model<IconButtonSize>("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. * 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. * 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; return this.elementRef.nativeElement;
} }
private elementRef = inject(ElementRef);
constructor() { constructor() {
const element = this.elementRef.nativeElement; const element = this.elementRef.nativeElement;
@@ -198,9 +203,15 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
effect(() => { effect(() => {
setA11yTitleAndAriaLabel({ setA11yTitleAndAriaLabel({
element: this.elementRef.nativeElement, element: this.elementRef.nativeElement,
title: originalTitle ?? this.label(), title: undefined,
label: this.label(), label: this.label(),
}); });
const tooltipContent: string = originalTitle || this.label();
if (tooltipContent) {
this.tooltip?.tooltipContent.set(tooltipContent);
}
}); });
} }
} }

View File

@@ -1,9 +1,11 @@
<div @if (tooltipData.content()) {
class="bit-tooltip-container" <div
[attr.data-position]="tooltipData.tooltipPosition()" class="bit-tooltip-container"
[attr.data-visible]="tooltipData.isVisible()" [attr.data-position]="tooltipData.tooltipPosition()"
> [attr.data-visible]="tooltipData.isVisible()"
<div role="tooltip" class="bit-tooltip"> >
<ng-content>{{ tooltipData.content() }}</ng-content> <div role="tooltip" class="bit-tooltip" [id]="tooltipData.id()">
<ng-content>{{ tooltipData.content() }}</ng-content>
</div>
</div> </div>
</div> }

View File

@@ -15,6 +15,7 @@ type TooltipData = {
content: Signal<string>; content: Signal<string>;
isVisible: Signal<boolean>; isVisible: Signal<boolean>;
tooltipPosition: Signal<TooltipPosition>; tooltipPosition: Signal<TooltipPosition>;
id: Signal<string>;
}; };
export const TOOLTIP_DATA = new InjectionToken<TooltipData>("TOOLTIP_DATA"); export const TOOLTIP_DATA = new InjectionToken<TooltipData>("TOOLTIP_DATA");

View File

@@ -8,8 +8,9 @@ import {
ElementRef, ElementRef,
Injector, Injector,
input, input,
effect,
signal, signal,
model,
computed,
} from "@angular/core"; } from "@angular/core";
import { TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions"; import { TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions";
@@ -26,30 +27,39 @@ import { TooltipComponent, TOOLTIP_DATA } from "./tooltip.component";
"(mouseleave)": "hideTooltip()", "(mouseleave)": "hideTooltip()",
"(focus)": "showTooltip()", "(focus)": "showTooltip()",
"(blur)": "hideTooltip()", "(blur)": "hideTooltip()",
"[attr.aria-describedby]": "resolvedDescribedByIds()",
}, },
}) })
export class TooltipDirective implements OnInit { export class TooltipDirective implements OnInit {
private static nextId = 0;
/** /**
* The value of this input is forwarded to the tooltip.component to render * The value of this input is forwarded to the tooltip.component to render
*/ */
readonly bitTooltip = input.required<string>(); readonly tooltipContent = model("", { alias: "bitTooltip" });
/** /**
* The value of this input is forwarded to the tooltip.component to set its position explicitly. * The value of this input is forwarded to the tooltip.component to set its position explicitly.
* @default "above-center" * @default "above-center"
*/ */
readonly tooltipPosition = input<TooltipPositionIdentifier>("above-center"); readonly tooltipPosition = input<TooltipPositionIdentifier>("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<boolean>(false);
private readonly isVisible = signal(false); private readonly isVisible = signal(false);
private overlayRef: OverlayRef | undefined; private overlayRef: OverlayRef | undefined;
private elementRef = inject(ElementRef); private elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private overlay = inject(Overlay); private overlay = inject(Overlay);
private viewContainerRef = inject(ViewContainerRef); private viewContainerRef = inject(ViewContainerRef);
private injector = inject(Injector);
private positionStrategy = this.overlay private positionStrategy = this.overlay
.position() .position()
.flexibleConnectedTo(this.elementRef) .flexibleConnectedTo(this.elementRef)
.withFlexibleDimensions(false) .withFlexibleDimensions(false)
.withPush(true); .withPush(true);
private tooltipId = `bit-tooltip-${TooltipDirective.nextId++}`;
private currentDescribedByIds =
this.elementRef.nativeElement.getAttribute("aria-describedby") || null;
private tooltipPortal = new ComponentPortal( private tooltipPortal = new ComponentPortal(
TooltipComponent, TooltipComponent,
@@ -59,23 +69,50 @@ export class TooltipDirective implements OnInit {
{ {
provide: TOOLTIP_DATA, provide: TOOLTIP_DATA,
useValue: { useValue: {
content: this.bitTooltip, content: this.tooltipContent,
isVisible: this.isVisible, isVisible: this.isVisible,
tooltipPosition: this.tooltipPosition, tooltipPosition: this.tooltipPosition,
id: signal(this.tooltipId),
}, },
}, },
], ],
}), }),
); );
private destroyTooltip = () => {
this.overlayRef?.dispose();
this.overlayRef = undefined;
this.isVisible.set(false);
};
private showTooltip = () => { private showTooltip = () => {
if (!this.overlayRef) {
this.overlayRef = this.overlay.create({
...this.defaultPopoverConfig,
positionStrategy: this.positionStrategy,
});
this.overlayRef.attach(this.tooltipPortal);
}
this.isVisible.set(true); this.isVisible.set(true);
}; };
private hideTooltip = () => { 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) { private computePositions(tooltipPosition: TooltipPositionIdentifier) {
const chosenPosition = tooltipPositions.find((position) => position.id === tooltipPosition); const chosenPosition = tooltipPositions.find((position) => position.id === tooltipPosition);
@@ -91,20 +128,5 @@ export class TooltipDirective implements OnInit {
ngOnInit() { ngOnInit() {
this.positionStrategy.withPositions(this.computePositions(this.tooltipPosition())); 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 },
);
} }
} }

View File

@@ -11,7 +11,20 @@ import { TooltipDirective } from "@bitwarden/components";
<Title /> <Title />
<Description /> <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 /> <Primary />
<Controls /> <Controls />
@@ -29,3 +42,7 @@ NOTE: The `TooltipComponent` can't be used on its own. It must be applied via th
### On disabled element ### On disabled element
<Canvas of={stories.OnDisabledButton} /> <Canvas of={stories.OnDisabledButton} />
### On a Button
<Canvas of={stories.OnNonIconButton} />

View File

@@ -59,7 +59,14 @@ describe("TooltipDirective (visibility only)", () => {
}; };
const overlayRefStub: OverlayRefStub = { 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(), updatePosition: jest.fn(),
}; };

View File

@@ -72,7 +72,6 @@ type Story = StoryObj<TooltipDirective>;
export const Default: Story = { export const Default: Story = {
args: { args: {
bitTooltip: "This is a tooltip",
tooltipPosition: "above-center", tooltipPosition: "above-center",
}, },
render: (args) => ({ render: (args) => ({
@@ -81,6 +80,7 @@ export const Default: Story = {
<div class="tw-p-4"> <div class="tw-p-4">
<button <button
bitIconButton="bwi-ellipsis-v" bitIconButton="bwi-ellipsis-v"
label="Your tooltip content here"
${formatArgsForCodeSnippet<TooltipDirective>(args)} ${formatArgsForCodeSnippet<TooltipDirective>(args)}
> >
Button label here Button label here
@@ -98,26 +98,29 @@ export const Default: Story = {
export const AllPositions: Story = { export const AllPositions: Story = {
render: () => ({ render: () => ({
parameters: {
chromatic: { disableSnapshot: true },
},
template: ` template: `
<div class="tw-p-16 tw-grid tw-grid-cols-2 tw-gap-8 tw-place-items-center"> <div class="tw-p-16 tw-grid tw-grid-cols-2 tw-gap-8 tw-place-items-center">
<button <button
bitIconButton="bwi-angle-up" bitIconButton="bwi-angle-up"
bitTooltip="Top tooltip" label="Top tooltip"
tooltipPosition="above-center" tooltipPosition="above-center"
></button> ></button>
<button <button
bitIconButton="bwi-angle-right" bitIconButton="bwi-angle-right"
bitTooltip="Right tooltip" label="Right tooltip"
tooltipPosition="right-center" tooltipPosition="right-center"
></button> ></button>
<button <button
bitIconButton="bwi-angle-left" bitIconButton="bwi-angle-left"
bitTooltip="Left tooltip" label="Left tooltip"
tooltipPosition="left-center" tooltipPosition="left-center"
></button> ></button>
<button <button
bitIconButton="bwi-angle-down" bitIconButton="bwi-angle-down"
bitTooltip="Bottom tooltip" label="Bottom tooltip"
tooltipPosition="below-center" tooltipPosition="below-center"
></button> ></button>
</div> </div>
@@ -127,11 +130,14 @@ export const AllPositions: Story = {
export const LongContent: Story = { export const LongContent: Story = {
render: () => ({ render: () => ({
parameters: {
chromatic: { disableSnapshot: true },
},
template: ` template: `
<div class="tw-p-16 tw-flex tw-items-center tw-justify-center"> <div class="tw-p-16 tw-flex tw-items-center tw-justify-center">
<button <button
bitIconButton="bwi-ellipsis-v" 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> ></button>
</div> </div>
`, `,
@@ -140,14 +146,34 @@ export const LongContent: Story = {
export const OnDisabledButton: Story = { export const OnDisabledButton: Story = {
render: () => ({ render: () => ({
parameters: {
chromatic: { disableSnapshot: true },
},
template: ` template: `
<div class="tw-p-16 tw-flex tw-items-center tw-justify-center"> <div class="tw-p-16 tw-flex tw-items-center tw-justify-center">
<button <button
bitIconButton="bwi-ellipsis-v" bitIconButton="bwi-ellipsis-v"
bitTooltip="Tooltip on disabled button" label="Tooltip on disabled button"
[disabled]="true" [disabled]="true"
></button> ></button>
</div> </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>
`,
}),
};

View File

@@ -68,7 +68,7 @@ describe("DeleteAttachmentComponent", () => {
it("renders delete button", () => { it("renders delete button", () => {
const deleteButton = fixture.debugElement.query(By.css("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 () => { it("does not delete when the user cancels the dialog", async () => {

View File

@@ -149,13 +149,17 @@ describe("UriOptionComponent", () => {
expect(getMatchDetectionSelect()).not.toBeNull(); 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 }); component.writeValue({ uri: "https://example.com", matchDetection: UriMatchStrategy.Exact });
fixture.detectChanges(); fixture.detectChanges();
expect(getToggleMatchDetectionBtn().title).toBe("showMatchDetection https://example.com"); expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe(
"showMatchDetection https://example.com",
);
getToggleMatchDetectionBtn().click(); getToggleMatchDetectionBtn().click();
fixture.detectChanges(); fixture.detectChanges();
expect(getToggleMatchDetectionBtn().title).toBe("hideMatchDetection https://example.com"); expect(getToggleMatchDetectionBtn().getAttribute("aria-label")).toBe(
"hideMatchDetection https://example.com",
);
}); });
}); });

View File

@@ -108,7 +108,7 @@ describe("DownloadAttachmentComponent", () => {
it("renders delete button", () => { it("renders delete button", () => {
const deleteButton = fixture.debugElement.query(By.css("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", () => { describe("download attachment", () => {