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:
@@ -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",
|
||||
]
|
||||
: [],
|
||||
)
|
||||
|
||||
@@ -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<IconButtonSize, string[]> = {
|
||||
*/
|
||||
"[attr.bitIconButton]": "icon()",
|
||||
},
|
||||
hostDirectives: [AriaDisableDirective],
|
||||
hostDirectives: [
|
||||
AriaDisableDirective,
|
||||
{ directive: TooltipDirective, inputs: ["tooltipPosition"] },
|
||||
],
|
||||
})
|
||||
export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableElement {
|
||||
readonly icon = model.required<string>({ alias: "bitIconButton" });
|
||||
@@ -109,6 +113,9 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
|
||||
|
||||
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.
|
||||
* 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<div
|
||||
class="bit-tooltip-container"
|
||||
[attr.data-position]="tooltipData.tooltipPosition()"
|
||||
[attr.data-visible]="tooltipData.isVisible()"
|
||||
>
|
||||
<div role="tooltip" class="bit-tooltip">
|
||||
<ng-content>{{ tooltipData.content() }}</ng-content>
|
||||
@if (tooltipData.content()) {
|
||||
<div
|
||||
class="bit-tooltip-container"
|
||||
[attr.data-position]="tooltipData.tooltipPosition()"
|
||||
[attr.data-visible]="tooltipData.isVisible()"
|
||||
>
|
||||
<div role="tooltip" class="bit-tooltip" [id]="tooltipData.id()">
|
||||
<ng-content>{{ tooltipData.content() }}</ng-content>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ type TooltipData = {
|
||||
content: Signal<string>;
|
||||
isVisible: Signal<boolean>;
|
||||
tooltipPosition: Signal<TooltipPosition>;
|
||||
id: Signal<string>;
|
||||
};
|
||||
|
||||
export const TOOLTIP_DATA = new InjectionToken<TooltipData>("TOOLTIP_DATA");
|
||||
|
||||
@@ -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<string>();
|
||||
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<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 overlayRef: OverlayRef | undefined;
|
||||
private elementRef = inject(ElementRef);
|
||||
private elementRef = inject<ElementRef<HTMLElement>>(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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,20 @@ import { TooltipDirective } from "@bitwarden/components";
|
||||
<Title />
|
||||
<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} />
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user