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

[CL-227] Tooltip component (#16442)

* add tooltip component

* fix typescript errors

* fix more typescript errors

* remove css comments

* fix tooltip blocking mouse events

* move default position logic to shared util

* fix tooltip stories options

* add tooltip spec

* add offset arg to default positions

* add shadow to tooltip

* increase offset

* adding max width

* fix disabled button cursor

* add stronger position type

* fixing types

* change get positions function to type return correctly

* more fixing types

* default options object

* add mock to tooltip stories

* add figma link to story

* update positions file name. remove getter

* remove standalone. add comment about component use

* add jsdoc comment to directive inputs

* fix typo

* remove instances of setInput

* fix storybook injection error

* remove unneeded functions

* remove unneeded variables

* remove comment

* move popover positions back with component

* fix popover i18n mock

* creat etooltip positions file

* update test to account for change to setInput calls

* remove panel class as it's not necessary

* improve tooltip docs page

* use classes for styling. Simpliy position changes

* simplify tests. No longer need to track position changes

* move comment to correct place

* fix typos

* remove unnecessary standalone declaration
This commit is contained in:
Bryan Cunningham
2025-10-01 14:01:53 -04:00
committed by GitHub
parent de3759fa85
commit 08a022fa52
12 changed files with 644 additions and 1 deletions

View File

@@ -130,7 +130,11 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
.concat(sizes[this.size()])
.concat(
this.showDisabledStyles() || this.disabled()
? ["aria-disabled:tw-opacity-60", "aria-disabled:hover:!tw-bg-transparent"]
? [
"aria-disabled:tw-opacity-60",
"aria-disabled:hover:!tw-bg-transparent",
"tw-cursor-default",
]
: [],
);
}

View File

@@ -23,6 +23,7 @@ export default {
useFactory: () => {
return new I18nMockService({
close: "Close",
loading: "Loading",
});
},
},

View File

@@ -0,0 +1 @@
export * from "./tooltip.directive";

View File

@@ -0,0 +1,61 @@
import { ConnectedPosition } from "@angular/cdk/overlay";
const ORIGIN_OFFSET_PX = 10;
export type TooltipPositionIdentifier =
| "right-center"
| "left-center"
| "below-center"
| "above-center";
export interface TooltipPosition extends ConnectedPosition {
id: TooltipPositionIdentifier;
}
export const tooltipPositions: TooltipPosition[] = [
/**
* The order of these positions matters. The Tooltip component will use
* the first position that fits within the viewport.
*/
// Tooltip opens to right of trigger
{
id: "right-center",
offsetX: ORIGIN_OFFSET_PX,
originX: "end",
originY: "center",
overlayX: "start",
overlayY: "center",
panelClass: ["bit-tooltip-right-center"],
},
// ... to left of trigger
{
id: "left-center",
offsetX: -ORIGIN_OFFSET_PX,
originX: "start",
originY: "center",
overlayX: "end",
overlayY: "center",
panelClass: ["bit-tooltip-left-center"],
},
// ... below trigger
{
id: "below-center",
offsetY: ORIGIN_OFFSET_PX,
originX: "center",
originY: "bottom",
overlayX: "center",
overlayY: "top",
panelClass: ["bit-tooltip-below-center"],
},
// ... above trigger
{
id: "above-center",
offsetY: -ORIGIN_OFFSET_PX,
originX: "center",
originY: "top",
overlayX: "center",
overlayY: "bottom",
panelClass: ["bit-tooltip-above-center"],
},
];

View File

@@ -0,0 +1,132 @@
:root {
--tooltip-shadow: rgb(0 0 0 / 0.1);
}
.cdk-overlay-pane:has(.bit-tooltip[data-visible="false"]) {
pointer-events: none;
}
.bit-tooltip-container {
position: relative;
max-width: 12rem;
opacity: 0;
width: max-content;
box-shadow:
0 4px 6px -1px var(--tooltip-shadow),
0 2px 4px -2px var(--tooltip-shadow);
border-radius: 0.75rem;
transition:
transform 100ms ease-in-out,
opacity 100ms ease-in-out;
transform: scale(0.95);
z-index: 1;
&::before,
&::after {
content: "";
position: absolute;
width: 1rem;
height: 1rem;
z-index: 1;
rotate: 45deg;
border-radius: 3px;
}
&::before {
background: linear-gradient(135deg, transparent 50%, rgb(var(--color-text-main)) 50%);
z-index: -1;
}
&::after {
background: rgb(var(--color-text-main));
z-index: -1;
}
&[data-visible="true"] {
opacity: 1;
transform: scale(1);
z-index: 1000;
}
.bit-tooltip-above-center &,
.bit-tooltip-below-center & {
&::before,
&::after {
inset-inline-start: 50%;
transform: translateX(-50%);
transform-origin: left;
}
}
.bit-tooltip-above-center & {
&::after {
filter: drop-shadow(0 3px 5px var(--tooltip-shadow))
drop-shadow(0 1px 3px var(--tooltip-shadow));
}
&::before,
&::after {
inset-block-end: -0.25rem;
}
}
.bit-tooltip-below-center & {
&::after {
display: none;
}
&::after,
&::before {
inset-block-start: -0.25rem;
rotate: -135deg;
}
}
.bit-tooltip-left-center &,
.bit-tooltip-right-center & {
&::after,
&::before {
inset-block-start: 50%;
transform: translateY(-50%);
transform-origin: top;
}
}
.bit-tooltip-left-center & {
&::after {
filter: drop-shadow(-3px 1px 3px var(--tooltip-shadow))
drop-shadow(-1px 2px 3px var(--tooltip-shadow));
}
&::after,
&::before {
inset-inline-end: -0.25rem;
rotate: -45deg;
}
}
.bit-tooltip-right-center & {
&::after {
filter: drop-shadow(2px -4px 2px var(--tooltip-shadow))
drop-shadow(0 -1px 3px var(--tooltip-shadow));
}
&::after,
&::before {
inset-inline-start: -0.25rem;
rotate: 135deg;
}
}
}
.bit-tooltip {
width: max-content;
max-width: 12rem;
background-color: rgb(var(--color-text-main));
color: rgb(var(--color-text-contrast));
padding: 0.5rem 0.75rem;
border-radius: 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
z-index: 2;
}

View File

@@ -0,0 +1,10 @@
<!-- eslint-disable-next-line tailwindcss/no-custom-classname -->
<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>
</div>
</div>

View File

@@ -0,0 +1,36 @@
import { CommonModule } from "@angular/common";
import {
Component,
ElementRef,
inject,
InjectionToken,
Signal,
TemplateRef,
viewChild,
} from "@angular/core";
import { TooltipPosition } from "./tooltip-positions";
type TooltipData = {
content: Signal<string>;
isVisible: Signal<boolean>;
tooltipPosition: Signal<TooltipPosition>;
};
export const TOOLTIP_DATA = new InjectionToken<TooltipData>("TOOLTIP_DATA");
/**
* tooltip component used internally by the tooltip.directive. Not meant to be used explicitly
*/
@Component({
selector: "bit-tooltip",
templateUrl: "./tooltip.component.html",
imports: [CommonModule],
})
export class TooltipComponent {
readonly templateRef = viewChild.required(TemplateRef);
private elementRef = inject(ElementRef<HTMLDivElement>);
readonly tooltipData = inject<TooltipData>(TOOLTIP_DATA);
}

View File

@@ -0,0 +1,110 @@
import { Overlay, OverlayConfig, OverlayRef } from "@angular/cdk/overlay";
import { ComponentPortal } from "@angular/cdk/portal";
import {
Directive,
ViewContainerRef,
inject,
OnInit,
ElementRef,
Injector,
input,
effect,
signal,
} from "@angular/core";
import { TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions";
import { TooltipComponent, TOOLTIP_DATA } from "./tooltip.component";
/**
* Directive to add a tooltip to any element. The tooltip content is provided via the `bitTooltip` input.
* The position of the tooltip can be set via the `tooltipPosition` input. Default position is "above-center".
*/
@Directive({
selector: "[bitTooltip]",
host: {
"(mouseenter)": "showTooltip()",
"(mouseleave)": "hideTooltip()",
"(focus)": "showTooltip()",
"(blur)": "hideTooltip()",
},
})
export class TooltipDirective implements OnInit {
/**
* The value of this input is forwarded to the tooltip.component to render
*/
readonly bitTooltip = input.required<string>();
/**
* 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");
private isVisible = signal(false);
private overlayRef: OverlayRef | undefined;
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 tooltipPortal = new ComponentPortal(
TooltipComponent,
this.viewContainerRef,
Injector.create({
providers: [
{
provide: TOOLTIP_DATA,
useValue: {
content: this.bitTooltip,
isVisible: this.isVisible,
tooltipPosition: this.tooltipPosition,
},
},
],
}),
);
private showTooltip = () => {
this.isVisible.set(true);
};
private hideTooltip = () => {
this.isVisible.set(false);
};
private computePositions(tooltipPosition: TooltipPositionIdentifier) {
const chosenPosition = tooltipPositions.find((position) => position.id === tooltipPosition);
return chosenPosition ? [chosenPosition, ...tooltipPositions] : tooltipPositions;
}
get defaultPopoverConfig(): OverlayConfig {
return {
hasBackdrop: false,
scrollStrategy: this.overlay.scrollStrategies.reposition(),
};
}
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 },
);
}
}

View File

@@ -0,0 +1,31 @@
import { Meta, Canvas, Source, Primary, Controls, Title, Description } from "@storybook/addon-docs";
import * as stories from "./tooltip.stories";
<Meta of={stories} />
```ts
import { TooltipDirective } from "@bitwarden/components";
```
<Title />
<Description />
NOTE: The `TooltipComponent` can't be used on its own. It must be applied via the `TooltipDirective`
<Primary />
<Controls />
## Stories
### All available positions
<Canvas of={stories.AllPositions} />
### Used with a long content
<Canvas of={stories.LongContent} />
### On disabled element
<Canvas of={stories.OnDisabledButton} />

View File

@@ -0,0 +1,103 @@
import {
ConnectedOverlayPositionChange,
ConnectionPositionPair,
OverlayConfig,
Overlay,
} from "@angular/cdk/overlay";
import { ComponentPortal } from "@angular/cdk/portal";
import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { Observable, Subject } from "rxjs";
import { TooltipDirective } from "./tooltip.directive";
@Component({
standalone: true,
imports: [TooltipDirective],
template: ` <button [bitTooltip]="tooltipText" type="button">Hover or focus me</button> `,
})
class TooltipHostComponent {
tooltipText = "Hello Tooltip";
}
/** Minimal strategy shape the directive expects */
interface StrategyLike {
withFlexibleDimensions: (flex: boolean) => StrategyLike;
withPush: (push: boolean) => StrategyLike;
withPositions: (positions: ReadonlyArray<ConnectionPositionPair>) => StrategyLike;
readonly positionChanges: Observable<ConnectedOverlayPositionChange>;
}
/** Minimal Overlay service shape */
interface OverlayLike {
position: () => { flexibleConnectedTo: (_: unknown) => StrategyLike };
create: (_: OverlayConfig) => OverlayRefStub;
scrollStrategies: { reposition: () => unknown };
}
interface OverlayRefStub {
attach: (portal: ComponentPortal<unknown>) => unknown;
updatePosition: () => void;
}
describe("TooltipDirective (visibility only)", () => {
let fixture: ComponentFixture<TooltipHostComponent>;
beforeEach(() => {
const positionChanges$ = new Subject<ConnectedOverlayPositionChange>();
const strategy: StrategyLike = {
withFlexibleDimensions: jest.fn(() => strategy),
withPush: jest.fn(() => strategy),
withPositions: jest.fn(() => strategy),
get positionChanges() {
return positionChanges$.asObservable();
},
};
const overlayRefStub: OverlayRefStub = {
attach: jest.fn(() => ({})),
updatePosition: jest.fn(),
};
const overlayMock: OverlayLike = {
position: () => ({ flexibleConnectedTo: () => strategy }),
create: (_: OverlayConfig) => overlayRefStub,
scrollStrategies: { reposition: () => ({}) },
};
TestBed.configureTestingModule({
imports: [TooltipHostComponent],
providers: [{ provide: Overlay, useValue: overlayMock as unknown as Overlay }],
});
fixture = TestBed.createComponent(TooltipHostComponent);
fixture.detectChanges();
});
function getDirective(): TooltipDirective {
const hostDE = fixture.debugElement.query(By.directive(TooltipDirective));
return hostDE.injector.get(TooltipDirective);
}
it("sets isVisible to true on mouseenter", () => {
const button: HTMLButtonElement = fixture.debugElement.query(By.css("button")).nativeElement;
const directive = getDirective();
const isVisible = (directive as unknown as { isVisible: () => boolean }).isVisible;
button.dispatchEvent(new Event("mouseenter"));
expect(isVisible()).toBe(true);
});
it("sets isVisible to true on focus", () => {
const button: HTMLButtonElement = fixture.debugElement.query(By.css("button")).nativeElement;
const directive = getDirective();
const isVisible = (directive as unknown as { isVisible: () => boolean }).isVisible;
button.dispatchEvent(new Event("focus"));
expect(isVisible()).toBe(true);
});
});

View File

@@ -0,0 +1,153 @@
import { signal } from "@angular/core";
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { getByRole, userEvent } from "@storybook/test";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ButtonComponent } from "../button";
import { BitIconButtonComponent } from "../icon-button";
import { I18nMockService } from "../utils";
import { TooltipPosition, TooltipPositionIdentifier, tooltipPositions } from "./tooltip-positions";
import { TOOLTIP_DATA, TooltipComponent } from "./tooltip.component";
import { TooltipDirective } from "./tooltip.directive";
import { formatArgsForCodeSnippet } from ".storybook/format-args-for-code-snippet";
export default {
title: "Component Library/Tooltip",
component: TooltipDirective,
decorators: [
moduleMetadata({
imports: [TooltipDirective, TooltipComponent, BitIconButtonComponent, ButtonComponent],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
loading: "Loading",
});
},
},
{
provide: TOOLTIP_DATA,
useFactory: () => {
// simple fixed demo values for the Default story
return {
content: signal("This is a tooltip"),
isVisible: signal(true),
tooltipPosition: signal<TooltipPositionIdentifier>("above-center"),
};
},
},
],
}),
],
parameters: {
design: {
type: "figma",
url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?m=auto&node-id=30558-13730&t=4k23PtzCwqDekAZW-1",
},
},
argTypes: {
bitTooltip: {
control: "text",
description: "Text content of the tooltip",
},
tooltipPosition: {
control: "select",
options: tooltipPositions.map((position: TooltipPosition) => position.id),
description: "Position of the tooltip relative to the element",
table: {
type: {
summary: tooltipPositions.map((position: TooltipPosition) => position.id).join(" | "),
},
defaultValue: { summary: "above-center" },
},
},
},
} as Meta<TooltipDirective>;
type Story = StoryObj<TooltipDirective>;
export const Default: Story = {
args: {
bitTooltip: "This is a tooltip",
tooltipPosition: "above-center",
},
render: (args) => ({
props: args,
template: `
<div class="tw-p-4">
<button
bitIconButton="bwi-ellipsis-v"
${formatArgsForCodeSnippet<TooltipDirective>(args)}
>
Button label here
</button>
</div>
`,
}),
play: async (context) => {
const canvasEl = context.canvasElement;
const button = getByRole(canvasEl, "button");
await userEvent.hover(button);
},
};
export const AllPositions: Story = {
render: () => ({
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"
tooltipPosition="above-center"
></button>
<button
bitIconButton="bwi-angle-right"
bitTooltip="Right tooltip"
tooltipPosition="right-center"
></button>
<button
bitIconButton="bwi-angle-left"
bitTooltip="Left tooltip"
tooltipPosition="left-center"
></button>
<button
bitIconButton="bwi-angle-down"
bitTooltip="Bottom tooltip"
tooltipPosition="below-center"
></button>
</div>
`,
}),
};
export const LongContent: Story = {
render: () => ({
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."
></button>
</div>
`,
}),
};
export const OnDisabledButton: Story = {
render: () => ({
template: `
<div class="tw-p-16 tw-flex tw-items-center tw-justify-center">
<button
bitIconButton="bwi-ellipsis-v"
bitTooltip="Tooltip on disabled button"
[disabled]="true"
></button>
</div>
`,
}),
};

View File

@@ -5,6 +5,7 @@
@import "./popover/popover.component.css";
@import "./toast/toast.tokens.css";
@import "./toast/toastr.css";
@import "./tooltip/tooltip.component.css";
@import "./search/search.component.css";
@tailwind base;