From 69a52b055420c430161b31fcab5c98215a5b9076 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Fri, 21 Apr 2023 20:34:43 +0200 Subject: [PATCH] chore: replace the tooltip package Signed-off-by: Gabriel --- packages/ui-components/tooltip/.eslintrc.json | 6 + packages/ui-components/tooltip/.prettierrc | 1 + packages/ui-components/tooltip/.swcrc | 19 + packages/ui-components/tooltip/README.md | 20 + packages/ui-components/tooltip/jest.config.js | 3 + .../src/__snapshots__/tooltip.test.tsx.snap | 245 +++++++++++ packages/ui-components/tooltip/src/helpers.ts | 141 ++++++ packages/ui-components/tooltip/src/index.ts | 7 + .../ui-components/tooltip/src/tooltip.scss | 92 ++++ .../tooltip/src/tooltip.test.tsx | 414 ++++++++++++++++++ .../ui-components/tooltip/src/tooltip.tsx | 171 ++++++++ .../ui-components/tooltip/src/withTooltip.tsx | 70 +++ packages/ui-components/tooltip/tsconfig.json | 4 + .../ui-components/tooltip/webpack.config.js | 1 + 14 files changed, 1194 insertions(+) create mode 100644 packages/ui-components/tooltip/.eslintrc.json create mode 100644 packages/ui-components/tooltip/.prettierrc create mode 100644 packages/ui-components/tooltip/.swcrc create mode 100644 packages/ui-components/tooltip/README.md create mode 100644 packages/ui-components/tooltip/jest.config.js create mode 100644 packages/ui-components/tooltip/src/__snapshots__/tooltip.test.tsx.snap create mode 100644 packages/ui-components/tooltip/src/helpers.ts create mode 100644 packages/ui-components/tooltip/src/index.ts create mode 100644 packages/ui-components/tooltip/src/tooltip.scss create mode 100644 packages/ui-components/tooltip/src/tooltip.test.tsx create mode 100644 packages/ui-components/tooltip/src/tooltip.tsx create mode 100644 packages/ui-components/tooltip/src/withTooltip.tsx create mode 100644 packages/ui-components/tooltip/tsconfig.json create mode 100644 packages/ui-components/tooltip/webpack.config.js diff --git a/packages/ui-components/tooltip/.eslintrc.json b/packages/ui-components/tooltip/.eslintrc.json new file mode 100644 index 0000000000..b15115cb69 --- /dev/null +++ b/packages/ui-components/tooltip/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": "@k8slens/eslint-config/eslint", + "parserOptions": { + "project": "./tsconfig.json" + } +} diff --git a/packages/ui-components/tooltip/.prettierrc b/packages/ui-components/tooltip/.prettierrc new file mode 100644 index 0000000000..edd47b479e --- /dev/null +++ b/packages/ui-components/tooltip/.prettierrc @@ -0,0 +1 @@ +"@k8slens/eslint-config/prettier" diff --git a/packages/ui-components/tooltip/.swcrc b/packages/ui-components/tooltip/.swcrc new file mode 100644 index 0000000000..4dd5c11a89 --- /dev/null +++ b/packages/ui-components/tooltip/.swcrc @@ -0,0 +1,19 @@ +{ + "module": { + "type": "commonjs" + }, + "jsc": { + "parser": { + "syntax": "typescript", + "tsx": true, + "decorators": true, + "dynamicImport": false + }, + "transform": { + "legacyDecorator": true, + "decoratorMetadata": true + }, + "target": "es2019" + } +} + diff --git a/packages/ui-components/tooltip/README.md b/packages/ui-components/tooltip/README.md new file mode 100644 index 0000000000..3dc391655e --- /dev/null +++ b/packages/ui-components/tooltip/README.md @@ -0,0 +1,20 @@ +# @k8slens/tooltip + +This package contains stuff related to creating Lens-applications. + +# Usage + +```bash +$ npm install @k8slens/tooltip +``` + +```typescript +import { Tooltip, TooltipPosition } from "@k8slens/tooltip"; +import { withTooltip } from "@k8slens/tooltip"; + +import type { TooltipProps } from "@k8slens/tooltip"; +import type { TooltipDecoratorProps } from "@k8slens/tooltip"; + +``` + +## Extendability diff --git a/packages/ui-components/tooltip/jest.config.js b/packages/ui-components/tooltip/jest.config.js new file mode 100644 index 0000000000..05ec7ecd4d --- /dev/null +++ b/packages/ui-components/tooltip/jest.config.js @@ -0,0 +1,3 @@ +const { configForReact } = require("@k8slens/jest").monorepoPackageConfig(__dirname); + +module.exports = configForReact; diff --git a/packages/ui-components/tooltip/src/__snapshots__/tooltip.test.tsx.snap b/packages/ui-components/tooltip/src/__snapshots__/tooltip.test.tsx.snap new file mode 100644 index 0000000000..babbf9d0cf --- /dev/null +++ b/packages/ui-components/tooltip/src/__snapshots__/tooltip.test.tsx.snap @@ -0,0 +1,245 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` does not render to DOM if not visible 1`] = ` + +
+
+ Target Text +
+
+ +`; + +exports[` renders to DOM when forced to by visible prop 1`] = ` + +
+ +
+ Target Text +
+
+ +`; + +exports[` renders to DOM when hovering over target 1`] = ` + +
+ +
+ Target Text +
+
+ +`; + +exports[` uses a portal if usePortal is specified 1`] = ` + +
+
+
+ Target Text +
+
+
+ + +`; + +exports[` when specifying a tooltip for a component renders 1`] = ` + +
+
+
+ Target Text +
+
+
+ +`; + +exports[` when specifying a tooltip for a component that doesn't exist with show on parent hover renders 1`] = ` + +
+ +`; + +exports[` when specifying a tooltip for a component when hovering over the target element renders 1`] = ` + +
+
+
+ Target Text +
+
+
+ + +`; + +exports[` when specifying a tooltip for a component when hovering over the target element when no longer hovering the target renders 1`] = ` + +
+
+
+ Target Text +
+
+
+ +`; + +exports[` when specifying a tooltip for a component with show on parent hover renders 1`] = ` + +
+
+
+
+ Target Text +
+
+
+
+ +`; + +exports[` when specifying a tooltip for a component with show on parent hover when hovering over the target element renders 1`] = ` + +
+
+
+
+ Target Text +
+
+
+
+ + +`; + +exports[` when specifying a tooltip for a component with show on parent hover when hovering over the target element when no longer hovering the target renders 1`] = ` + +
+
+
+
+ Target Text +
+
+
+
+ +`; + +exports[` when specifying a tooltip for a component with show on parent hover when hovering over the target's parent element renders 1`] = ` + +
+
+
+
+ Target Text +
+
+
+
+ + +`; + +exports[` when specifying a tooltip for a component with show on parent hover when hovering over the target's parent element when no longer hovering the target's parent renders 1`] = ` + +
+
+
+
+ Target Text +
+
+
+
+ +`; diff --git a/packages/ui-components/tooltip/src/helpers.ts b/packages/ui-components/tooltip/src/helpers.ts new file mode 100644 index 0000000000..ab346f1142 --- /dev/null +++ b/packages/ui-components/tooltip/src/helpers.ts @@ -0,0 +1,141 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { iter } from "@k8slens/utilities"; +import { TooltipPosition } from "./tooltip"; + +export type RectangleDimensions = { + left: number; + width: number; + top: number; + height: number; + bottom: number; + right: number; +}; + +export type FloatingRectangleDimensions = { + width: number; + height: number; +}; + +export type DomElementWithRectangle = { + getBoundingClientRect: () => RectangleDimensions; +}; + +export type DomElementWithFloatingRectangle = { + getBoundingClientRect: () => FloatingRectangleDimensions; +}; + +export type ComputeNextPositionArgs = { + tooltip: DomElementWithFloatingRectangle; + target: DomElementWithRectangle; + offset: number; + preferredPositions?: TooltipPosition | TooltipPosition[]; +}; + +export type NextPosition = { + position: TooltipPosition; + top: number; + left: number; +}; + +type GetPositionArgs = { + offset: number; + position: TooltipPosition; + tooltipBounds: FloatingRectangleDimensions; + targetBounds: RectangleDimensions; +}; + +const getPosition = ({ offset, position, tooltipBounds, targetBounds }: GetPositionArgs) => { + const horizontalCenter = targetBounds.left + (targetBounds.width - tooltipBounds.width) / 2; + const verticalCenter = targetBounds.top + (targetBounds.height - tooltipBounds.height) / 2; + const topCenter = targetBounds.top - tooltipBounds.height - offset; + const bottomCenter = targetBounds.bottom + offset; + const [left, top] = (() => { + switch (position) { + case TooltipPosition.TOP: + return [horizontalCenter, topCenter]; + case TooltipPosition.BOTTOM: + return [horizontalCenter, bottomCenter]; + case TooltipPosition.LEFT: + return [targetBounds.left - tooltipBounds.width - offset, verticalCenter]; + case TooltipPosition.RIGHT: + return [targetBounds.right + offset, verticalCenter]; + case TooltipPosition.TOP_LEFT: + return [targetBounds.left, topCenter]; + case TooltipPosition.TOP_RIGHT: + return [targetBounds.right - tooltipBounds.width, topCenter]; + case TooltipPosition.BOTTOM_LEFT: + return [targetBounds.left, bottomCenter]; + case TooltipPosition.BOTTOM_RIGHT: + return [targetBounds.right - tooltipBounds.width, bottomCenter]; + } + })(); + + return { + left, + top, + right: left + tooltipBounds.width, + bottom: top + tooltipBounds.height, + }; +}; + +const isTooltipPosition = (value: unknown): value is TooltipPosition => + Object.values(TooltipPosition).includes(value as TooltipPosition); + +export const computeNextPosition = ({ + offset, + preferredPositions, + target, + tooltip, +}: ComputeNextPositionArgs): NextPosition => { + const positions = new Set([ + ...[preferredPositions ?? []].filter(isTooltipPosition).flat(), + TooltipPosition.RIGHT, + TooltipPosition.BOTTOM, + TooltipPosition.TOP, + TooltipPosition.LEFT, + TooltipPosition.TOP_RIGHT, + TooltipPosition.TOP_LEFT, + TooltipPosition.BOTTOM_RIGHT, + TooltipPosition.BOTTOM_LEFT, + ]); + + const tooltipBounds = tooltip.getBoundingClientRect(); + const targetBounds = target.getBoundingClientRect(); + const { innerWidth: viewportWidth, innerHeight: viewportHeight } = window; + + // find proper position + for (const position of positions) { + const { left, top, right, bottom } = getPosition({ + offset, + position, + tooltipBounds, + targetBounds, + }); + const fitsToWindow = + left >= 0 && top >= 0 && right <= viewportWidth && bottom <= viewportHeight; + + if (fitsToWindow) { + return { + left, + top, + position, + }; + } + } + + const position = iter.first(positions) as TooltipPosition; + + return { + position, + ...getPosition({ + offset, + position, + tooltipBounds, + targetBounds, + }), + }; +}; diff --git a/packages/ui-components/tooltip/src/index.ts b/packages/ui-components/tooltip/src/index.ts new file mode 100644 index 0000000000..b507e8fc97 --- /dev/null +++ b/packages/ui-components/tooltip/src/index.ts @@ -0,0 +1,7 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +export * from "./tooltip"; +export * from "./withTooltip"; diff --git a/packages/ui-components/tooltip/src/tooltip.scss b/packages/ui-components/tooltip/src/tooltip.scss new file mode 100644 index 0000000000..28db216a4e --- /dev/null +++ b/packages/ui-components/tooltip/src/tooltip.scss @@ -0,0 +1,92 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + + +.Tooltip { + + position: fixed; + margin: 0 !important; + background: var(--mainBackground); + font-size: small; + font-weight: normal; + border-radius: 3px; + color: var(--textColorAccent); + white-space: normal; + padding: .5em; + text-align: center; + pointer-events: none; + transition: opacity 150ms 150ms ease-in-out; + z-index: 100000; + box-shadow: 0 8px 16px rgba(0,0,0,0.24); + left: 0; + top: 0; + opacity: 0; + visibility: hidden; + + &.visible { + opacity: 1; + visibility: visible; + } + + &:empty { + display: none; + } + + &.formatter { + &.nowrap { + &, * { + white-space: nowrap; + } + } + + &.narrow { + max-width: 300px; + text-overflow: ellipsis; + word-wrap: break-word; + text-align: left; + } + + &.small { + font-size: 12px; + } + + &.warning { + color: var(--colorError); + } + + &.tableView { + display: grid; + gap: var(--padding); + grid-template-columns: max-content 1fr; + grid-template-rows: repeat(2, 1fr); + + > .flex { + display: contents; + } + + > * { + white-space: pre-wrap; + word-break: break-word; + } + + .title { + grid-column: 1 / 3; + color: var(--textColorAccent); + text-align: center; + font-weight: bold; + } + + .name { + text-align: right; + color: var(--textColorAccent); + } + + .value { + text-align: left; + color: var(--textColorSecondary); + } + } + } +} diff --git a/packages/ui-components/tooltip/src/tooltip.test.tsx b/packages/ui-components/tooltip/src/tooltip.test.tsx new file mode 100644 index 0000000000..b47c337552 --- /dev/null +++ b/packages/ui-components/tooltip/src/tooltip.test.tsx @@ -0,0 +1,414 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { render } from "@testing-library/react"; +import type { RenderResult } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import assert from "assert"; +import React from "react"; +import { computeNextPosition, RectangleDimensions } from "./helpers"; +import { Tooltip, TooltipPosition } from "./tooltip"; + +const getRectangle = ( + parts: Omit, +): RectangleDimensions => { + assert(parts.right >= parts.left); + assert(parts.bottom >= parts.top); + + return { + ...parts, + width: parts.right - parts.left, + height: parts.bottom - parts.top, + }; +}; + +describe("", () => { + let requestAnimationFrameSpy: jest.SpyInstance; + + beforeEach(() => { + requestAnimationFrameSpy = jest.spyOn(window, "requestAnimationFrame"); + + requestAnimationFrameSpy.mockImplementation((cb) => { + cb(0); + + return 0; + }); + }); + + afterEach(() => { + requestAnimationFrameSpy.mockRestore(); + }); + + it("does not render to DOM if not visible", () => { + const result = render( + <> + + I am a tooltip + +
Target Text
+ , + ); + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("renders to DOM when hovering over target", () => { + const result = render( + <> + + I am a tooltip + +
Target Text
+ , + ); + + const target = result.baseElement.querySelector("#my-target"); + + assert(target); + + userEvent.hover(target); + expect(result.baseElement).toMatchSnapshot(); + }); + + it("renders to DOM when forced to by visible prop", () => { + const result = render( + <> + + I am a tooltip + +
Target Text
+ , + ); + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("uses a portal if usePortal is specified", () => { + const result = render( +
+ + I am a tooltip + +
Target Text
+
, + ); + + expect(result.baseElement).toMatchSnapshot(); + }); + + describe("when specifying a tooltip for a component", () => { + let result: RenderResult; + + beforeEach(() => { + result = render( +
+ + I am a tooltip + +
+ Target Text +
+
, + ); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + it("doesn't render the tooltip", () => { + expect(result.queryByTestId("tooltip")).not.toBeInTheDocument(); + }); + + describe("when hovering over the target element", () => { + beforeEach(() => { + userEvent.hover(result.getByTestId("target")); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + it("renders the tooltip", () => { + expect(result.getByTestId("tooltip")).toBeInTheDocument(); + }); + + describe("when no longer hovering the target", () => { + beforeEach(() => { + userEvent.unhover(result.getByTestId("target")); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + it("doesn't render the tooltip", () => { + expect(result.queryByTestId("tooltip")).not.toBeInTheDocument(); + }); + }); + }); + }); + + describe("when specifying a tooltip for a component with show on parent hover", () => { + let result: RenderResult; + + beforeEach(() => { + result = render( +
+ + I am a tooltip + +
+
+ Target Text +
+
+
, + ); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + it("doesn't render the tooltip", () => { + expect(result.queryByTestId("tooltip")).not.toBeInTheDocument(); + }); + + describe("when hovering over the target element", () => { + beforeEach(() => { + userEvent.hover(result.getByTestId("target")); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + it("renders the tooltip", () => { + expect(result.getByTestId("tooltip")).toBeInTheDocument(); + }); + + describe("when no longer hovering the target", () => { + beforeEach(() => { + userEvent.unhover(result.getByTestId("target")); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + it("doesn't render the tooltip", () => { + expect(result.queryByTestId("tooltip")).not.toBeInTheDocument(); + }); + }); + }); + + describe("when hovering over the target's parent element", () => { + beforeEach(() => { + userEvent.hover(result.getByTestId("target-parent")); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + it("renders the tooltip", () => { + expect(result.getByTestId("tooltip")).toBeInTheDocument(); + }); + + describe("when no longer hovering the target's parent", () => { + beforeEach(() => { + userEvent.unhover(result.getByTestId("target-parent")); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + it("doesn't render the tooltip", () => { + expect(result.queryByTestId("tooltip")).not.toBeInTheDocument(); + }); + }); + }); + }); + + describe("when specifying a tooltip for a component that doesn't exist with show on parent hover", () => { + let result: RenderResult; + + beforeEach(() => { + result = render( + <> + + I am a tooltip + + , + ); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + it("doesn't render the tooltip", () => { + expect(result.queryByTestId("tooltip")).not.toBeInTheDocument(); + }); + }); +}); + +describe("computeNextPosition technical tests", () => { + describe("given a 1280x720 window", () => { + beforeEach(() => { + window.innerHeight = 720; + window.innerWidth = 1280; + }); + + [ + { + position: "right", + targetRect: { + top: 700, + left: 700, + bottom: 720, + right: 720, + }, + }, + { + position: "bottom", + targetRect: { + top: 500, + left: 1200, + bottom: 520, + right: 1280, + }, + }, + { + position: "top", + targetRect: { + top: 700, + left: 1200, + bottom: 720, + right: 1280, + }, + }, + { + position: "left", + targetRect: { + top: 0, + left: 1200, + bottom: 720, + right: 1280, + }, + }, + ].forEach(({ position, targetRect }) => { + it(`renders to the "${position}" by default if there is enough room`, () => { + expect( + computeNextPosition({ + offset: 10, + target: { + getBoundingClientRect: () => getRectangle(targetRect), + }, + tooltip: { + getBoundingClientRect: () => ({ + height: 20, + width: 20, + }), + }, + }), + ).toMatchObject({ + position, + }); + }); + }); + + it("doesn't throw if the preferredPosition is invalid", () => { + expect( + computeNextPosition({ + preferredPositions: "some-invalid-data" as any, + offset: 10, + target: { + getBoundingClientRect: () => + getRectangle({ + top: 50, + left: 50, + bottom: 100, + right: 100, + }), + }, + tooltip: { + getBoundingClientRect: () => ({ + height: 20, + width: 20, + }), + }, + }), + ).toMatchObject({ + position: "right", + }); + }); + + it("defaults to right if no other location works", () => { + expect( + computeNextPosition({ + offset: 10, + target: { + getBoundingClientRect: () => + getRectangle({ + top: 0, + left: 0, + bottom: 720, + right: 1280, + }), + }, + tooltip: { + getBoundingClientRect: () => ({ + height: 20, + width: 20, + }), + }, + }), + ).toMatchObject({ + position: "right", + }); + }); + + it.each([ + TooltipPosition.RIGHT, + TooltipPosition.BOTTOM, + TooltipPosition.TOP, + TooltipPosition.LEFT, + TooltipPosition.TOP_RIGHT, + TooltipPosition.TOP_LEFT, + TooltipPosition.BOTTOM_RIGHT, + TooltipPosition.BOTTOM_LEFT, + ])( + "computes to the %p if there is space and it is specified as a preferred position", + (position) => { + expect( + computeNextPosition({ + preferredPositions: position, + offset: 10, + target: { + getBoundingClientRect: () => + getRectangle({ + top: 50, + left: 50, + bottom: 100, + right: 100, + }), + }, + tooltip: { + getBoundingClientRect: () => ({ + height: 20, + width: 20, + }), + }, + }), + ).toMatchObject({ + position, + }); + }, + ); + }); +}); diff --git a/packages/ui-components/tooltip/src/tooltip.tsx b/packages/ui-components/tooltip/src/tooltip.tsx new file mode 100644 index 0000000000..c2f80b4d05 --- /dev/null +++ b/packages/ui-components/tooltip/src/tooltip.tsx @@ -0,0 +1,171 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "./tooltip.scss"; + +import React from "react"; +import { createPortal } from "react-dom"; +import { observer } from "mobx-react"; +import type { IClassName } from "@k8slens/utilities"; +import { cssNames } from "@k8slens/utilities"; +import { observable, makeObservable, action, runInAction } from "mobx"; +import autoBindReact from "auto-bind/react"; +import { computeNextPosition } from "./helpers"; + +export enum TooltipPosition { + TOP = "top", + BOTTOM = "bottom", + LEFT = "left", + RIGHT = "right", + TOP_LEFT = "top_left", + TOP_RIGHT = "top_right", + BOTTOM_LEFT = "bottom_left", + BOTTOM_RIGHT = "bottom_right", +} + +export interface TooltipProps { + targetId: string; // html-id of target element to bind for + tooltipOnParentHover?: boolean; // detect hover on parent of target + visible?: boolean; // initial visibility + offset?: number; // offset from target element in pixels (all sides) + usePortal?: boolean; // renders element outside of parent (in body), disable for "easy-styling", default: true + preferredPositions?: TooltipPosition | TooltipPosition[]; + className?: IClassName; + formatters?: TooltipContentFormatters; + style?: React.CSSProperties; + children?: React.ReactNode; + "data-testid"?: string; +} + +export interface TooltipContentFormatters { + narrow?: boolean; // max-width + warning?: boolean; // color + small?: boolean; // font-size + nowrap?: boolean; // white-space + tableView?: boolean; +} + +const defaultProps = { + usePortal: true, + offset: 10, +}; + +@observer +class DefaultedTooltip extends React.Component { + static defaultProps = defaultProps as object; + + @observable.ref elem: HTMLDivElement | null = null; + + @observable activePosition?: TooltipPosition; + + @observable isVisible = false; + + @observable isContentVisible = false; // animation manager + + constructor(props: TooltipProps & typeof defaultProps) { + super(props); + makeObservable(this); + autoBindReact(this); + } + + get targetElem(): HTMLElement | null { + return document.getElementById(this.props.targetId); + } + + get hoverTarget(): HTMLElement | null { + if (this.props.tooltipOnParentHover) { + return this.targetElem?.parentElement ?? null; + } + + return this.targetElem; + } + + componentDidMount() { + this.hoverTarget?.addEventListener("mouseenter", this.onEnterTarget); + this.hoverTarget?.addEventListener("mouseleave", this.onLeaveTarget); + this.refreshPosition(); + } + + componentDidUpdate() { + this.refreshPosition(); + } + + componentWillUnmount() { + this.hoverTarget?.removeEventListener("mouseenter", this.onEnterTarget); + this.hoverTarget?.removeEventListener("mouseleave", this.onLeaveTarget); + } + + @action + protected onEnterTarget() { + this.isVisible = true; + requestAnimationFrame(action(() => (this.isContentVisible = true))); + } + + @action + protected onLeaveTarget() { + this.isVisible = false; + this.isContentVisible = false; + } + + refreshPosition() { + const { preferredPositions, offset } = this.props; + const { elem, targetElem } = this; + + if (!elem || !targetElem) { + return; + } + + this.setPosition(elem, { left: 0, top: 0 }); + + const { position, ...location } = computeNextPosition({ + offset, + preferredPositions, + target: targetElem, + tooltip: elem, + }); + + runInAction(() => { + this.activePosition = position; + this.setPosition(elem, location); + }); + } + + protected setPosition(elem: HTMLDivElement, pos: { left: number; top: number }) { + elem.style.left = `${pos.left}px`; + elem.style.top = `${pos.top}px`; + } + + render() { + const { style, formatters, usePortal, children, visible = this.isVisible } = this.props; + + if (!visible) { + return null; + } + + const className = cssNames("Tooltip", this.props.className, formatters, this.activePosition, { + visible: this.isContentVisible || this.props.visible, + formatter: !!formatters, + }); + const tooltip = ( +
(this.elem = elem)} + role="tooltip" + data-testid={this.props["data-testid"]} + > + {children} +
+ ); + + if (usePortal) { + return createPortal(tooltip, document.body); + } + + return tooltip; + } +} + +export const Tooltip = DefaultedTooltip as React.ComponentClass; diff --git a/packages/ui-components/tooltip/src/withTooltip.tsx b/packages/ui-components/tooltip/src/withTooltip.tsx new file mode 100644 index 0000000000..819b4b1bd4 --- /dev/null +++ b/packages/ui-components/tooltip/src/withTooltip.tsx @@ -0,0 +1,70 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ReactNode } from "react"; +import React, { useState } from "react"; +import type { TooltipProps } from "./tooltip"; +import { Tooltip } from "./tooltip"; +import { isReactNode } from "@k8slens/utilities"; +import uniqueId from "lodash/uniqueId"; +import type { SingleOrMany } from "@k8slens/utilities"; + +export interface TooltipDecoratorProps { + tooltip?: ReactNode | Omit; + /** + * forces tooltip to detect the target's parent for mouse events. This is + * useful for displaying tooltips even when the target is "disabled" + */ + tooltipOverrideDisabled?: boolean; + id?: string; + children?: SingleOrMany; +} + +export function withTooltip( + Target: TargetProps extends Pick + ? React.FunctionComponent + : never, +): React.FunctionComponent { + const DecoratedComponent = (props: TargetProps & TooltipDecoratorProps) => { + // TODO: Remove side-effect to allow deterministic unit testing + const [defaultTooltipId] = useState(uniqueId("tooltip_target_")); + + let { id: targetId, children: targetChildren } = props; + const { + tooltip, + tooltipOverrideDisabled, + id: _unusedId, + children: _unusedTargetChildren, + ...targetProps + } = props; + + if (tooltip) { + const tooltipProps: TooltipProps = { + targetId: targetId || defaultTooltipId, + tooltipOnParentHover: tooltipOverrideDisabled, + formatters: { narrow: true }, + ...(isReactNode(tooltip) ? { children: tooltip } : tooltip), + }; + + targetId = tooltipProps.targetId; + targetChildren = ( + <> +
{targetChildren}
+ + + ); + } + + return ( + + {targetChildren} + + ); + }; + + DecoratedComponent.displayName = `withTooltip(${Target.displayName || Target.name})`; + + return DecoratedComponent; +} diff --git a/packages/ui-components/tooltip/tsconfig.json b/packages/ui-components/tooltip/tsconfig.json new file mode 100644 index 0000000000..9e140d79da --- /dev/null +++ b/packages/ui-components/tooltip/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "@k8slens/typescript/config/base.json", + "include": ["**/*.ts", "**/*.tsx"], +} diff --git a/packages/ui-components/tooltip/webpack.config.js b/packages/ui-components/tooltip/webpack.config.js new file mode 100644 index 0000000000..1cda407f5a --- /dev/null +++ b/packages/ui-components/tooltip/webpack.config.js @@ -0,0 +1 @@ +module.exports = require("@k8slens/webpack").configForReact;