mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
chore: replace the tooltip package
Signed-off-by: Gabriel <gaccettola@mirantis.com>
This commit is contained in:
parent
54f4743095
commit
69a52b0554
6
packages/ui-components/tooltip/.eslintrc.json
Normal file
6
packages/ui-components/tooltip/.eslintrc.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@k8slens/eslint-config/eslint",
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
}
|
||||
}
|
||||
1
packages/ui-components/tooltip/.prettierrc
Normal file
1
packages/ui-components/tooltip/.prettierrc
Normal file
@ -0,0 +1 @@
|
||||
"@k8slens/eslint-config/prettier"
|
||||
19
packages/ui-components/tooltip/.swcrc
Normal file
19
packages/ui-components/tooltip/.swcrc
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"module": {
|
||||
"type": "commonjs"
|
||||
},
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"tsx": true,
|
||||
"decorators": true,
|
||||
"dynamicImport": false
|
||||
},
|
||||
"transform": {
|
||||
"legacyDecorator": true,
|
||||
"decoratorMetadata": true
|
||||
},
|
||||
"target": "es2019"
|
||||
}
|
||||
}
|
||||
|
||||
20
packages/ui-components/tooltip/README.md
Normal file
20
packages/ui-components/tooltip/README.md
Normal file
@ -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
|
||||
3
packages/ui-components/tooltip/jest.config.js
Normal file
3
packages/ui-components/tooltip/jest.config.js
Normal file
@ -0,0 +1,3 @@
|
||||
const { configForReact } = require("@k8slens/jest").monorepoPackageConfig(__dirname);
|
||||
|
||||
module.exports = configForReact;
|
||||
@ -0,0 +1,245 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`<Tooltip /> does not render to DOM if not visible 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
id="my-target"
|
||||
>
|
||||
Target Text
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`<Tooltip /> renders to DOM when forced to by visible prop 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="Tooltip right visible"
|
||||
data-testid="tooltip"
|
||||
role="tooltip"
|
||||
style="left: 10px; top: 0px;"
|
||||
>
|
||||
I am a tooltip
|
||||
</div>
|
||||
<div
|
||||
id="my-target"
|
||||
>
|
||||
Target Text
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`<Tooltip /> renders to DOM when hovering over target 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="Tooltip right visible"
|
||||
data-testid="tooltip"
|
||||
role="tooltip"
|
||||
style="left: 10px; top: 0px;"
|
||||
>
|
||||
I am a tooltip
|
||||
</div>
|
||||
<div
|
||||
id="my-target"
|
||||
>
|
||||
Target Text
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`<Tooltip /> uses a portal if usePortal is specified 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
id="my-target"
|
||||
>
|
||||
Target Text
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="Tooltip right visible"
|
||||
data-testid="tooltip"
|
||||
role="tooltip"
|
||||
style="left: 10px; top: 0px;"
|
||||
>
|
||||
I am a tooltip
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`<Tooltip /> when specifying a tooltip for a component renders 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
data-testid="target"
|
||||
id="my-target"
|
||||
>
|
||||
Target Text
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`<Tooltip /> when specifying a tooltip for a component that doesn't exist with show on parent hover renders 1`] = `
|
||||
<body>
|
||||
<div />
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`<Tooltip /> when specifying a tooltip for a component when hovering over the target element renders 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
data-testid="target"
|
||||
id="my-target"
|
||||
>
|
||||
Target Text
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="Tooltip right visible"
|
||||
data-testid="tooltip"
|
||||
role="tooltip"
|
||||
style="left: 10px; top: 0px;"
|
||||
>
|
||||
I am a tooltip
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`<Tooltip /> when specifying a tooltip for a component when hovering over the target element when no longer hovering the target renders 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
data-testid="target"
|
||||
id="my-target"
|
||||
>
|
||||
Target Text
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`<Tooltip /> when specifying a tooltip for a component with show on parent hover renders 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
data-testid="target-parent"
|
||||
>
|
||||
<div
|
||||
data-testid="target"
|
||||
id="my-target"
|
||||
>
|
||||
Target Text
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`<Tooltip /> when specifying a tooltip for a component with show on parent hover when hovering over the target element renders 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
data-testid="target-parent"
|
||||
>
|
||||
<div
|
||||
data-testid="target"
|
||||
id="my-target"
|
||||
>
|
||||
Target Text
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="Tooltip right visible"
|
||||
data-testid="tooltip"
|
||||
role="tooltip"
|
||||
style="left: 10px; top: 0px;"
|
||||
>
|
||||
I am a tooltip
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`<Tooltip /> 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`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
data-testid="target-parent"
|
||||
>
|
||||
<div
|
||||
data-testid="target"
|
||||
id="my-target"
|
||||
>
|
||||
Target Text
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`<Tooltip /> when specifying a tooltip for a component with show on parent hover when hovering over the target's parent element renders 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
data-testid="target-parent"
|
||||
>
|
||||
<div
|
||||
data-testid="target"
|
||||
id="my-target"
|
||||
>
|
||||
Target Text
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="Tooltip right visible"
|
||||
data-testid="tooltip"
|
||||
role="tooltip"
|
||||
style="left: 10px; top: 0px;"
|
||||
>
|
||||
I am a tooltip
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
|
||||
exports[`<Tooltip /> 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`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div>
|
||||
<div
|
||||
data-testid="target-parent"
|
||||
>
|
||||
<div
|
||||
data-testid="target"
|
||||
id="my-target"
|
||||
>
|
||||
Target Text
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
141
packages/ui-components/tooltip/src/helpers.ts
Normal file
141
packages/ui-components/tooltip/src/helpers.ts
Normal file
@ -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<TooltipPosition>([
|
||||
...[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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
7
packages/ui-components/tooltip/src/index.ts
Normal file
7
packages/ui-components/tooltip/src/index.ts
Normal file
@ -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";
|
||||
92
packages/ui-components/tooltip/src/tooltip.scss
Normal file
92
packages/ui-components/tooltip/src/tooltip.scss
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
414
packages/ui-components/tooltip/src/tooltip.test.tsx
Normal file
414
packages/ui-components/tooltip/src/tooltip.test.tsx
Normal file
@ -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, "width" | "height">,
|
||||
): RectangleDimensions => {
|
||||
assert(parts.right >= parts.left);
|
||||
assert(parts.bottom >= parts.top);
|
||||
|
||||
return {
|
||||
...parts,
|
||||
width: parts.right - parts.left,
|
||||
height: parts.bottom - parts.top,
|
||||
};
|
||||
};
|
||||
|
||||
describe("<Tooltip />", () => {
|
||||
let requestAnimationFrameSpy: jest.SpyInstance<number, [callback: FrameRequestCallback]>;
|
||||
|
||||
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(
|
||||
<>
|
||||
<Tooltip targetId="my-target" usePortal={false}>
|
||||
I am a tooltip
|
||||
</Tooltip>
|
||||
<div id="my-target">Target Text</div>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(result.baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders to DOM when hovering over target", () => {
|
||||
const result = render(
|
||||
<>
|
||||
<Tooltip targetId="my-target" data-testid="tooltip" usePortal={false}>
|
||||
I am a tooltip
|
||||
</Tooltip>
|
||||
<div id="my-target">Target Text</div>
|
||||
</>,
|
||||
);
|
||||
|
||||
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(
|
||||
<>
|
||||
<Tooltip targetId="my-target" data-testid="tooltip" visible={true} usePortal={false}>
|
||||
I am a tooltip
|
||||
</Tooltip>
|
||||
<div id="my-target">Target Text</div>
|
||||
</>,
|
||||
);
|
||||
|
||||
expect(result.baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("uses a portal if usePortal is specified", () => {
|
||||
const result = render(
|
||||
<div>
|
||||
<Tooltip targetId="my-target" data-testid="tooltip" visible={true} usePortal>
|
||||
I am a tooltip
|
||||
</Tooltip>
|
||||
<div id="my-target">Target Text</div>
|
||||
</div>,
|
||||
);
|
||||
|
||||
expect(result.baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe("when specifying a tooltip for a component", () => {
|
||||
let result: RenderResult;
|
||||
|
||||
beforeEach(() => {
|
||||
result = render(
|
||||
<div>
|
||||
<Tooltip targetId="my-target" data-testid="tooltip">
|
||||
I am a tooltip
|
||||
</Tooltip>
|
||||
<div id="my-target" data-testid="target">
|
||||
Target Text
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<div>
|
||||
<Tooltip targetId="my-target" data-testid="tooltip" tooltipOnParentHover>
|
||||
I am a tooltip
|
||||
</Tooltip>
|
||||
<div data-testid="target-parent">
|
||||
<div id="my-target" data-testid="target">
|
||||
Target Text
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<>
|
||||
<Tooltip targetId="my-target" data-testid="tooltip" tooltipOnParentHover>
|
||||
I am a tooltip
|
||||
</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,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
171
packages/ui-components/tooltip/src/tooltip.tsx
Normal file
171
packages/ui-components/tooltip/src/tooltip.tsx
Normal file
@ -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<TooltipProps & typeof defaultProps> {
|
||||
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 = (
|
||||
<div
|
||||
className={className}
|
||||
style={style}
|
||||
ref={(elem) => (this.elem = elem)}
|
||||
role="tooltip"
|
||||
data-testid={this.props["data-testid"]}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (usePortal) {
|
||||
return createPortal(tooltip, document.body);
|
||||
}
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
}
|
||||
|
||||
export const Tooltip = DefaultedTooltip as React.ComponentClass<TooltipProps>;
|
||||
70
packages/ui-components/tooltip/src/withTooltip.tsx
Normal file
70
packages/ui-components/tooltip/src/withTooltip.tsx
Normal file
@ -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<TooltipProps, "targetId">;
|
||||
/**
|
||||
* 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<React.ReactNode>;
|
||||
}
|
||||
|
||||
export function withTooltip<TargetProps>(
|
||||
Target: TargetProps extends Pick<TooltipDecoratorProps, "id" | "children">
|
||||
? React.FunctionComponent<TargetProps>
|
||||
: never,
|
||||
): React.FunctionComponent<TargetProps & TooltipDecoratorProps> {
|
||||
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 = (
|
||||
<>
|
||||
<div>{targetChildren}</div>
|
||||
<Tooltip {...tooltipProps} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Target id={targetId} {...(targetProps as any)}>
|
||||
{targetChildren}
|
||||
</Target>
|
||||
);
|
||||
};
|
||||
|
||||
DecoratedComponent.displayName = `withTooltip(${Target.displayName || Target.name})`;
|
||||
|
||||
return DecoratedComponent;
|
||||
}
|
||||
4
packages/ui-components/tooltip/tsconfig.json
Normal file
4
packages/ui-components/tooltip/tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@k8slens/typescript/config/base.json",
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
}
|
||||
1
packages/ui-components/tooltip/webpack.config.js
Normal file
1
packages/ui-components/tooltip/webpack.config.js
Normal file
@ -0,0 +1 @@
|
||||
module.exports = require("@k8slens/webpack").configForReact;
|
||||
Loading…
Reference in New Issue
Block a user