mirror of
https://github.com/bitwarden/browser
synced 2025-12-10 05:13:29 +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:
@@ -130,7 +130,11 @@ export class BitIconButtonComponent implements ButtonLikeAbstraction, FocusableE
|
|||||||
.concat(sizes[this.size()])
|
.concat(sizes[this.size()])
|
||||||
.concat(
|
.concat(
|
||||||
this.showDisabledStyles() || this.disabled()
|
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",
|
||||||
|
]
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export default {
|
|||||||
useFactory: () => {
|
useFactory: () => {
|
||||||
return new I18nMockService({
|
return new I18nMockService({
|
||||||
close: "Close",
|
close: "Close",
|
||||||
|
loading: "Loading",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
1
libs/components/src/tooltip/index.ts
Normal file
1
libs/components/src/tooltip/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./tooltip.directive";
|
||||||
61
libs/components/src/tooltip/tooltip-positions.ts
Normal file
61
libs/components/src/tooltip/tooltip-positions.ts
Normal 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"],
|
||||||
|
},
|
||||||
|
];
|
||||||
132
libs/components/src/tooltip/tooltip.component.css
Normal file
132
libs/components/src/tooltip/tooltip.component.css
Normal 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;
|
||||||
|
}
|
||||||
10
libs/components/src/tooltip/tooltip.component.html
Normal file
10
libs/components/src/tooltip/tooltip.component.html
Normal 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>
|
||||||
36
libs/components/src/tooltip/tooltip.component.ts
Normal file
36
libs/components/src/tooltip/tooltip.component.ts
Normal 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);
|
||||||
|
}
|
||||||
110
libs/components/src/tooltip/tooltip.directive.ts
Normal file
110
libs/components/src/tooltip/tooltip.directive.ts
Normal 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
31
libs/components/src/tooltip/tooltip.mdx
Normal file
31
libs/components/src/tooltip/tooltip.mdx
Normal 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} />
|
||||||
103
libs/components/src/tooltip/tooltip.spec.ts
Normal file
103
libs/components/src/tooltip/tooltip.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
153
libs/components/src/tooltip/tooltip.stories.ts
Normal file
153
libs/components/src/tooltip/tooltip.stories.ts
Normal 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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
@import "./popover/popover.component.css";
|
@import "./popover/popover.component.css";
|
||||||
@import "./toast/toast.tokens.css";
|
@import "./toast/toast.tokens.css";
|
||||||
@import "./toast/toastr.css";
|
@import "./toast/toastr.css";
|
||||||
|
@import "./tooltip/tooltip.component.css";
|
||||||
@import "./search/search.component.css";
|
@import "./search/search.component.css";
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
|
|||||||
Reference in New Issue
Block a user