diff --git a/libs/components/src/icon-button/icon-button.component.ts b/libs/components/src/icon-button/icon-button.component.ts index 6bb6ccf10b..d712d5cb2b 100644 --- a/libs/components/src/icon-button/icon-button.component.ts +++ b/libs/components/src/icon-button/icon-button.component.ts @@ -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", + ] : [], ); } diff --git a/libs/components/src/popover/popover.stories.ts b/libs/components/src/popover/popover.stories.ts index 6d2cf77e47..596381d077 100644 --- a/libs/components/src/popover/popover.stories.ts +++ b/libs/components/src/popover/popover.stories.ts @@ -23,6 +23,7 @@ export default { useFactory: () => { return new I18nMockService({ close: "Close", + loading: "Loading", }); }, }, diff --git a/libs/components/src/tooltip/index.ts b/libs/components/src/tooltip/index.ts new file mode 100644 index 0000000000..28c35fd6ee --- /dev/null +++ b/libs/components/src/tooltip/index.ts @@ -0,0 +1 @@ +export * from "./tooltip.directive"; diff --git a/libs/components/src/tooltip/tooltip-positions.ts b/libs/components/src/tooltip/tooltip-positions.ts new file mode 100644 index 0000000000..6396bb6632 --- /dev/null +++ b/libs/components/src/tooltip/tooltip-positions.ts @@ -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"], + }, +]; diff --git a/libs/components/src/tooltip/tooltip.component.css b/libs/components/src/tooltip/tooltip.component.css new file mode 100644 index 0000000000..4abb9908f2 --- /dev/null +++ b/libs/components/src/tooltip/tooltip.component.css @@ -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; +} diff --git a/libs/components/src/tooltip/tooltip.component.html b/libs/components/src/tooltip/tooltip.component.html new file mode 100644 index 0000000000..c75cd5fb0d --- /dev/null +++ b/libs/components/src/tooltip/tooltip.component.html @@ -0,0 +1,10 @@ + +
+ +
diff --git a/libs/components/src/tooltip/tooltip.component.ts b/libs/components/src/tooltip/tooltip.component.ts new file mode 100644 index 0000000000..6b24050731 --- /dev/null +++ b/libs/components/src/tooltip/tooltip.component.ts @@ -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; + isVisible: Signal; + tooltipPosition: Signal; +}; + +export const TOOLTIP_DATA = new InjectionToken("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); + + readonly tooltipData = inject(TOOLTIP_DATA); +} diff --git a/libs/components/src/tooltip/tooltip.directive.ts b/libs/components/src/tooltip/tooltip.directive.ts new file mode 100644 index 0000000000..153fecfe7b --- /dev/null +++ b/libs/components/src/tooltip/tooltip.directive.ts @@ -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(); + /** + * The value of this input is forwarded to the tooltip.component to set its position explicitly. + * @default "above-center" + */ + readonly tooltipPosition = input("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 }, + ); + } +} diff --git a/libs/components/src/tooltip/tooltip.mdx b/libs/components/src/tooltip/tooltip.mdx new file mode 100644 index 0000000000..4b6f10d97f --- /dev/null +++ b/libs/components/src/tooltip/tooltip.mdx @@ -0,0 +1,31 @@ +import { Meta, Canvas, Source, Primary, Controls, Title, Description } from "@storybook/addon-docs"; + +import * as stories from "./tooltip.stories"; + + + +```ts +import { TooltipDirective } from "@bitwarden/components"; +``` + + +<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} /> diff --git a/libs/components/src/tooltip/tooltip.spec.ts b/libs/components/src/tooltip/tooltip.spec.ts new file mode 100644 index 0000000000..57e05e4f65 --- /dev/null +++ b/libs/components/src/tooltip/tooltip.spec.ts @@ -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); + }); +}); diff --git a/libs/components/src/tooltip/tooltip.stories.ts b/libs/components/src/tooltip/tooltip.stories.ts new file mode 100644 index 0000000000..8ea3f52f91 --- /dev/null +++ b/libs/components/src/tooltip/tooltip.stories.ts @@ -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> + `, + }), +}; diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index ec29bc522e..1e0a6f438f 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -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;