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`] = `
+
+
+
+`;
+
+exports[` renders to DOM when forced to by visible prop 1`] = `
+
+
+
+ I am a tooltip
+
+
+ Target Text
+
+
+
+`;
+
+exports[` renders to DOM when hovering over target 1`] = `
+
+
+
+ I am a tooltip
+
+
+ Target Text
+
+
+
+`;
+
+exports[` uses a portal if usePortal is specified 1`] = `
+
+
+
+ I am a tooltip
+
+
+`;
+
+exports[` when specifying a tooltip for a component renders 1`] = `
+
+
+
+`;
+
+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`] = `
+
+
+
+ I am a tooltip
+
+
+`;
+
+exports[` when specifying a tooltip for a component when hovering over the target element when no longer hovering the target renders 1`] = `
+
+
+
+`;
+
+exports[` when specifying a tooltip for a component with show on parent hover renders 1`] = `
+
+
+
+`;
+
+exports[` when specifying a tooltip for a component with show on parent hover when hovering over the target element renders 1`] = `
+
+
+
+ I am a tooltip
+
+
+`;
+
+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`] = `
+
+
+
+`;
+
+exports[` when specifying a tooltip for a component with show on parent hover when hovering over the target's parent element renders 1`] = `
+
+
+
+ I am a tooltip
+
+
+`;
+
+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`] = `
+
+
+
+`;
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(
+ ,
+ );
+ });
+
+ 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;