diff --git a/packages/core/src/renderer/components/+events/events.tsx b/packages/core/src/renderer/components/+events/events.tsx index d68bb21a53..51c03191fc 100644 --- a/packages/core/src/renderer/components/+events/events.tsx +++ b/packages/core/src/renderer/components/+events/events.tsx @@ -16,7 +16,7 @@ import { KubeObjectListLayout } from "../kube-object-list-layout"; import type { KubeEvent, KubeEventApi, KubeEventData } from "../../../common/k8s-api/endpoints/events.api"; import type { TableSortCallbacks, TableSortParams } from "../table"; import type { HeaderCustomizer } from "../item-object-list"; -import { Tooltip } from "../tooltip"; +import { Tooltip } from "@k8slens/tooltip"; import { Link } from "react-router-dom"; import type { IClassName } from "../../utils"; import { cssNames, stopPropagation } from "../../utils"; diff --git a/packages/core/src/renderer/components/+extensions/install.tsx b/packages/core/src/renderer/components/+extensions/install.tsx index 7572063796..e418a0bf43 100644 --- a/packages/core/src/renderer/components/+extensions/install.tsx +++ b/packages/core/src/renderer/components/+extensions/install.tsx @@ -11,7 +11,7 @@ import { Icon } from "../icon"; import { observer } from "mobx-react"; import { Input, InputValidators } from "../input"; import { SubTitle } from "../layout/sub-title"; -import { TooltipPosition } from "../tooltip"; +import { TooltipPosition } from "@k8slens/tooltip"; import type { ExtensionInstallationStateStore } from "../../../extensions/extension-installation-state-store/extension-installation-state-store"; import extensionInstallationStateStoreInjectable from "../../../extensions/extension-installation-state-store/extension-installation-state-store.injectable"; import { withInjectables } from "@ogre-tools/injectable-react"; diff --git a/packages/core/src/renderer/components/+namespaces/subnamespace-badge.tsx b/packages/core/src/renderer/components/+namespaces/subnamespace-badge.tsx index 4ae652acc6..2cf5bfe2a6 100644 --- a/packages/core/src/renderer/components/+namespaces/subnamespace-badge.tsx +++ b/packages/core/src/renderer/components/+namespaces/subnamespace-badge.tsx @@ -5,7 +5,7 @@ import styles from "./subnamespace-badge.module.scss"; import React from "react"; -import { Tooltip } from "../tooltip"; +import { Tooltip } from "@k8slens/tooltip"; import { cssNames } from "../../utils"; interface SubnamespaceBadgeProps extends React.HTMLAttributes { diff --git a/packages/core/src/renderer/components/+nodes/route.tsx b/packages/core/src/renderer/components/+nodes/route.tsx index a868dda39b..1a232151a6 100644 --- a/packages/core/src/renderer/components/+nodes/route.tsx +++ b/packages/core/src/renderer/components/+nodes/route.tsx @@ -13,7 +13,7 @@ import type { Node } from "../../../common/k8s-api/endpoints/node.api"; import { formatNodeTaint } from "../../../common/k8s-api/endpoints/node.api"; import { LineProgress } from "../line-progress"; import { bytesToUnits } from "../../../common/utils/convertMemory"; -import { Tooltip, TooltipPosition } from "../tooltip"; +import { Tooltip, TooltipPosition } from "@k8slens/tooltip"; import kebabCase from "lodash/kebabCase"; import upperFirst from "lodash/upperFirst"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; diff --git a/packages/core/src/renderer/components/+user-management/+cluster-role-bindings/dialog/view.tsx b/packages/core/src/renderer/components/+user-management/+cluster-role-bindings/dialog/view.tsx index 3dd8549ca1..60237b232c 100644 --- a/packages/core/src/renderer/components/+user-management/+cluster-role-bindings/dialog/view.tsx +++ b/packages/core/src/renderer/components/+user-management/+cluster-role-bindings/dialog/view.tsx @@ -20,7 +20,7 @@ import { onMultiSelectFor, Select } from "../../../select"; import { Wizard, WizardStep } from "../../../wizard"; import { ObservableHashSet, nFircate } from "../../../../utils"; import { Input } from "../../../input"; -import { TooltipPosition } from "../../../tooltip"; +import { TooltipPosition } from "@k8slens/tooltip"; import type { Subject } from "../../../../../common/k8s-api/endpoints/types/subject"; import type { ClusterRoleBindingDialogState } from "./state.injectable"; import type { ClusterRoleStore } from "../../+cluster-roles/store"; diff --git a/packages/core/src/renderer/components/+workloads-overview/overview.tsx b/packages/core/src/renderer/components/+workloads-overview/overview.tsx index 8b60676da2..ea0162b02a 100644 --- a/packages/core/src/renderer/components/+workloads-overview/overview.tsx +++ b/packages/core/src/renderer/components/+workloads-overview/overview.tsx @@ -15,7 +15,7 @@ import type { IComputedValue } from "mobx"; import { makeObservable, observable, reaction } from "mobx"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; import { Icon } from "../icon"; -import { TooltipPosition } from "../tooltip"; +import { TooltipPosition } from "@k8slens/tooltip"; import { withInjectables } from "@ogre-tools/injectable-react"; import clusterFrameContextForNamespacedResourcesInjectable from "../../cluster-frame-context/for-namespaced-resources.injectable"; import type { SubscribeStores } from "../../kube-watch-api/kube-watch-api"; diff --git a/packages/core/src/renderer/components/badge/badge.tsx b/packages/core/src/renderer/components/badge/badge.tsx index f0efb701bd..4f14be3909 100644 --- a/packages/core/src/renderer/components/badge/badge.tsx +++ b/packages/core/src/renderer/components/badge/badge.tsx @@ -9,7 +9,7 @@ import React, { useEffect, useRef, useState } from "react"; import { action, observable } from "mobx"; import { observer } from "mobx-react"; import { cssNames } from "../../utils/cssNames"; -import { withTooltip } from "../tooltip"; +import { withTooltip } from "@k8slens/tooltip"; export interface BadgeProps extends React.HTMLAttributes { small?: boolean; diff --git a/packages/core/src/renderer/components/button/button.tsx b/packages/core/src/renderer/components/button/button.tsx index 8168c2abf3..f3f008567b 100644 --- a/packages/core/src/renderer/components/button/button.tsx +++ b/packages/core/src/renderer/components/button/button.tsx @@ -7,7 +7,7 @@ import "./button.scss"; import type { ButtonHTMLAttributes } from "react"; import React from "react"; import { cssNames } from "../../utils"; -import { withTooltip } from "../tooltip"; +import { withTooltip } from "@k8slens/tooltip"; export interface ButtonProps extends ButtonHTMLAttributes { label?: React.ReactNode; diff --git a/packages/core/src/renderer/components/dock/dock-tab.tsx b/packages/core/src/renderer/components/dock/dock-tab.tsx index 5a672e2fb3..fb7c5321c1 100644 --- a/packages/core/src/renderer/components/dock/dock-tab.tsx +++ b/packages/core/src/renderer/components/dock/dock-tab.tsx @@ -16,7 +16,7 @@ import { Menu, MenuItem } from "../menu"; import { observable } from "mobx"; import { withInjectables } from "@ogre-tools/injectable-react"; import dockStoreInjectable from "./dock/store.injectable"; -import { Tooltip, TooltipPosition } from "../tooltip"; +import { Tooltip, TooltipPosition } from "@k8slens/tooltip"; import isMacInjectable from "../../../common/vars/is-mac.injectable"; export interface DockTabProps extends TabProps { diff --git a/packages/core/src/renderer/components/hotbar/hotbar-icon.tsx b/packages/core/src/renderer/components/hotbar/hotbar-icon.tsx index 3bbe644c88..c8229eb586 100644 --- a/packages/core/src/renderer/components/hotbar/hotbar-icon.tsx +++ b/packages/core/src/renderer/components/hotbar/hotbar-icon.tsx @@ -14,7 +14,7 @@ import { observer } from "mobx-react"; import type { AvatarProps } from "../avatar"; import { Avatar } from "../avatar"; import { Icon } from "../icon"; -import { Tooltip } from "../tooltip"; +import { Tooltip } from "@k8slens/tooltip"; import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-menu-item.injectable"; import { withInjectables } from "@ogre-tools/injectable-react"; import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable"; diff --git a/packages/core/src/renderer/components/hotbar/hotbar-selector.tsx b/packages/core/src/renderer/components/hotbar/hotbar-selector.tsx index 4d6104b5b3..f2ef242a22 100644 --- a/packages/core/src/renderer/components/hotbar/hotbar-selector.tsx +++ b/packages/core/src/renderer/components/hotbar/hotbar-selector.tsx @@ -9,7 +9,7 @@ import { Icon } from "../icon"; import { Badge } from "../badge"; import hotbarStoreInjectable from "../../../common/hotbars/store.injectable"; import { HotbarSwitchCommand } from "./hotbar-switch-command"; -import { Tooltip, TooltipPosition } from "../tooltip"; +import { Tooltip, TooltipPosition } from "@k8slens/tooltip"; import { observer } from "mobx-react"; import type { Hotbar } from "../../../common/hotbars/types"; import { withInjectables } from "@ogre-tools/injectable-react"; diff --git a/packages/core/src/renderer/components/icon/icon.tsx b/packages/core/src/renderer/components/icon/icon.tsx index b51299ba15..aef52515a5 100644 --- a/packages/core/src/renderer/components/icon/icon.tsx +++ b/packages/core/src/renderer/components/icon/icon.tsx @@ -10,7 +10,7 @@ import React, { createRef } from "react"; import { NavLink } from "react-router-dom"; import type { LocationDescriptor } from "history"; import { cssNames } from "../../utils"; -import { withTooltip } from "../tooltip"; +import { withTooltip } from "@k8slens/tooltip"; import isNumber from "lodash/isNumber"; import Configuration from "./configuration.svg"; import Crane from "./crane.svg"; diff --git a/packages/core/src/renderer/components/input/input.tsx b/packages/core/src/renderer/components/input/input.tsx index bbbfa60a86..bf90ecc9ca 100644 --- a/packages/core/src/renderer/components/input/input.tsx +++ b/packages/core/src/renderer/components/input/input.tsx @@ -10,8 +10,8 @@ import React from "react"; import type { SingleOrMany } from "../../utils"; import { autoBind, cssNames, debouncePromise, getRandId, isPromiseSettledFulfilled } from "../../utils"; import { Icon } from "../icon"; -import type { TooltipProps } from "../tooltip"; -import { Tooltip } from "../tooltip"; +import type { TooltipProps } from "@k8slens/tooltip"; +import { Tooltip } from "@k8slens/tooltip"; import * as Validators from "./input_validators"; import type { InputValidator, InputValidation, InputValidationResult, SyncValidationMessage } from "./input_validators"; import uniqueId from "lodash/uniqueId"; diff --git a/packages/core/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx b/packages/core/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx index 4394902072..d13e79ec00 100644 --- a/packages/core/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx +++ b/packages/core/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx @@ -18,7 +18,7 @@ import { KubeObjectMenu } from "../kube-object-menu"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; import { ResourceKindMap, ResourceNames } from "../../utils/rbac"; import { Icon } from "../icon"; -import { TooltipPosition } from "../tooltip"; +import { TooltipPosition } from "@k8slens/tooltip"; import { withInjectables } from "@ogre-tools/injectable-react"; import clusterFrameContextForNamespacedResourcesInjectable from "../../cluster-frame-context/for-namespaced-resources.injectable"; import type { SubscribableStore, SubscribeStores } from "../../kube-watch-api/kube-watch-api"; diff --git a/packages/core/src/renderer/components/layout/sidebar-cluster.tsx b/packages/core/src/renderer/components/layout/sidebar-cluster.tsx index 24b0622a85..a4afcf47b8 100644 --- a/packages/core/src/renderer/components/layout/sidebar-cluster.tsx +++ b/packages/core/src/renderer/components/layout/sidebar-cluster.tsx @@ -12,7 +12,7 @@ import { IpcRendererNavigationEvents } from "../../../common/ipc/navigation-even import { Avatar } from "../avatar"; import { Icon } from "../icon"; import { Menu, MenuItem } from "../menu"; -import { Tooltip } from "../tooltip"; +import { Tooltip } from "@k8slens/tooltip"; import { withInjectables } from "@ogre-tools/injectable-react"; import hotbarStoreInjectable from "../../../common/hotbars/store.injectable"; import type { HotbarStore } from "../../../common/hotbars/store"; diff --git a/packages/core/src/renderer/components/line-progress/line-progress.tsx b/packages/core/src/renderer/components/line-progress/line-progress.tsx index 24ef169176..fca3aade39 100644 --- a/packages/core/src/renderer/components/line-progress/line-progress.tsx +++ b/packages/core/src/renderer/components/line-progress/line-progress.tsx @@ -6,7 +6,7 @@ import "./line-progress.scss"; import React from "react"; import { cssNames } from "../../utils"; -import { withTooltip } from "../tooltip"; +import { withTooltip } from "@k8slens/tooltip"; export interface LineProgressProps extends React.HTMLProps { value: number; diff --git a/packages/core/src/renderer/components/menu/menu-actions.tsx b/packages/core/src/renderer/components/menu/menu-actions.tsx index 0e77446caf..13d5762758 100644 --- a/packages/core/src/renderer/components/menu/menu-actions.tsx +++ b/packages/core/src/renderer/components/menu/menu-actions.tsx @@ -14,7 +14,7 @@ import { Icon } from "../icon"; import type { MenuProps } from "./menu"; import { Menu, MenuItem } from "./menu"; import isString from "lodash/isString"; -import type { TooltipDecoratorProps } from "../tooltip"; +import type { TooltipDecoratorProps } from "@k8slens/tooltip"; import type { OpenConfirmDialog } from "../confirm-dialog/open.injectable"; import { withInjectables } from "@ogre-tools/injectable-react"; import openConfirmDialogInjectable from "../confirm-dialog/open.injectable"; diff --git a/packages/core/src/renderer/components/status-brick/status-brick.tsx b/packages/core/src/renderer/components/status-brick/status-brick.tsx index a9cc89fed2..2a6b34a61a 100644 --- a/packages/core/src/renderer/components/status-brick/status-brick.tsx +++ b/packages/core/src/renderer/components/status-brick/status-brick.tsx @@ -7,7 +7,7 @@ import "./status-brick.scss"; import React from "react"; import { cssNames } from "../../utils"; -import { withTooltip } from "../tooltip"; +import { withTooltip } from "@k8slens/tooltip"; export interface StatusBrickProps extends React.HTMLAttributes { } diff --git a/packages/core/src/renderer/components/tooltip/tooltip.test.tsx b/packages/core/src/renderer/components/tooltip/tooltip.test.tsx index 6c8331be8a..5fd336b208 100644 --- a/packages/core/src/renderer/components/tooltip/tooltip.test.tsx +++ b/packages/core/src/renderer/components/tooltip/tooltip.test.tsx @@ -7,7 +7,7 @@ import { render } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import assert from "assert"; import React from "react"; -import { Tooltip } from "./tooltip"; +import { Tooltip } from "@k8slens/tooltip"; describe("", () => { let requestAnimationFrameSpy: jest.SpyInstance; diff --git a/packages/core/src/renderer/components/tooltip/withTooltip.tsx b/packages/core/src/renderer/components/tooltip/withTooltip.tsx index 931653dc8e..fcd7d68e1a 100644 --- a/packages/core/src/renderer/components/tooltip/withTooltip.tsx +++ b/packages/core/src/renderer/components/tooltip/withTooltip.tsx @@ -7,8 +7,8 @@ import type { ReactNode } from "react"; import React, { useState } from "react"; -import type { TooltipProps } from "./tooltip"; -import { Tooltip } from "./tooltip"; +import type { TooltipProps } from "@k8slens/tooltip"; +import { Tooltip } from "@k8slens/tooltip"; import { isReactNode } from "../../utils/isReactNode"; import uniqueId from "lodash/uniqueId"; import type { SingleOrMany } from "../../utils"; 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/README.md b/packages/ui-components/tooltip/README.md new file mode 100644 index 0000000000..c132f8a4c9 --- /dev/null +++ b/packages/ui-components/tooltip/README.md @@ -0,0 +1,21 @@ +# @k8slens/tooltip + +This package contains stuff related to creating Lens-applications. + +# Usage + +```bash +$ npm install @k8slens/tooltip +``` + +```typescript +import { tooltipFeature } from "@k8slens/tooltip"; +import { registerFeature } from "@k8slens/feature-core"; +import { createContainer } from "@ogre-tools/injectable"; + +const di = createContainer("some-container"); + +registerFeature(di, tooltipFeature); +``` + +## Extendability diff --git a/packages/ui-components/tooltip/index.ts b/packages/ui-components/tooltip/index.ts new file mode 100644 index 0000000000..cad2ff6ec9 --- /dev/null +++ b/packages/ui-components/tooltip/index.ts @@ -0,0 +1,2 @@ +export * from "./src/tooltip"; +export * from "./src/withTooltip"; \ No newline at end of file diff --git a/packages/ui-components/tooltip/jest.config.js b/packages/ui-components/tooltip/jest.config.js new file mode 100644 index 0000000000..38d54ab7b6 --- /dev/null +++ b/packages/ui-components/tooltip/jest.config.js @@ -0,0 +1 @@ +module.exports = require("@k8slens/jest").monorepoPackageConfig(__dirname).configForReact; diff --git a/packages/ui-components/tooltip/package.json b/packages/ui-components/tooltip/package.json new file mode 100644 index 0000000000..6015d2bf32 --- /dev/null +++ b/packages/ui-components/tooltip/package.json @@ -0,0 +1,45 @@ +{ + "name": "@k8slens/tooltip", + "private": false, + "version": "1.0.0-alpha.0", + "description": "Highly extendable tooltip in the Lens.", + "type": "commonjs", + "files": [ + "dist" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/lensapp/lens.git" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "author": { + "name": "OpenLens Authors", + "email": "info@k8slens.dev" + }, + "license": "MIT", + "homepage": "https://github.com/lensapp/lens", + "scripts": { + "buildasd": "webpack", + "devasdasd": "webpack --mode=development --watch", + "test:unit": "jest --coverage --runInBand", + "lint": "lens-lint", + "lint:fix": "lens-lint --fix" + }, + "peerDependencies": { + "@k8slens/feature-core": "^6.5.0-alpha.0", + "@ogre-tools/injectable": "^15.1.2", + "@ogre-tools/injectable-extension-for-auto-registration": "^15.1.2", + "@ogre-tools/fp": "^15.1.2", + "lodash": "^4.17.21" + }, + "devDependencies": { + "@async-fn/jest": "^1.6.4", + "@k8slens/eslint-config": "6.5.0-alpha.1", + "@k8slens/react-testing-library-discovery": "^1.0.0-alpha.0" + } +} diff --git a/packages/ui-components/tooltip/src/__mocks__/withTooltip.tsx b/packages/ui-components/tooltip/src/__mocks__/withTooltip.tsx new file mode 100644 index 0000000000..cbaa591d91 --- /dev/null +++ b/packages/ui-components/tooltip/src/__mocks__/withTooltip.tsx @@ -0,0 +1,24 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import React from "react"; + +export const withTooltip = + (Target: any) => + ({ tooltip, tooltipOverrideDisabled, ...props }: any) => { + if (tooltip) { + const testId = props["data-testid"]; + + return ( + <> + +
+ {tooltip.children || tooltip} +
+ + ); + } + + return ; + }; 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..1e93b81e3a --- /dev/null +++ b/packages/ui-components/tooltip/src/__snapshots__/tooltip.test.tsx.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` does not render to DOM if not visibile 1`] = ` + +
+
+ Target Text +
+
+ +`; + +exports[` renders to DOM when forced to by visibile prop 1`] = ` + +
+ +
+ Target Text +
+
+ +`; + +exports[` renders to DOM when hovering over target 1`] = ` + +
+ +
+ Target Text +
+
+ +`; 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..d90d91f52e --- /dev/null +++ b/packages/ui-components/tooltip/src/tooltip.scss @@ -0,0 +1,99 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + + +.Tooltip { + --bgc: var(--mainBackground); + --radius: #{$radius}; + --color: var(--textColorAccent); + --border: 1px solid var(--borderColor); + + // use positioning relative to viewport (window) + // https://developer.mozilla.org/en-US/docs/Web/CSS/position + position: fixed; + margin: 0 !important; + background: var(--bgc); + font-size: small; + font-weight: normal; + border-radius: var(--radius); + color: var(--color); + 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: $font-size-small; + } + + &.warning { + color: var(--colorError); + } + + &.tableView { + display: grid; + gap: var(--padding); + grid-template-columns: max-content 1fr; + grid-template-rows: repeat(2, 1fr); + + // backward compatibility: skips element in DOM to consider only children in grid-flow + > .flex { + display: contents; + } + + > * { + white-space: pre-wrap; + word-break: break-word; + } + + .title { + grid-column: 1 / 3; // merge + 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..5fd336b208 --- /dev/null +++ b/packages/ui-components/tooltip/src/tooltip.test.tsx @@ -0,0 +1,80 @@ +/** + * 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 userEvent from "@testing-library/user-event"; +import assert from "assert"; +import React from "react"; +import { Tooltip } from "@k8slens/tooltip"; + +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 visibile", () => { + 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 visibile prop", () => { + const result = render(( + <> + + I am a tooltip + +
Target Text
+ + )); + + expect(result.baseElement).toMatchSnapshot(); + }); +}); diff --git a/packages/ui-components/tooltip/src/tooltip.tsx b/packages/ui-components/tooltip/src/tooltip.tsx new file mode 100644 index 0000000000..5c2f512f2f --- /dev/null +++ b/packages/ui-components/tooltip/src/tooltip.tsx @@ -0,0 +1,243 @@ +/** + * 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 } from "mobx"; +import autoBindReact from "auto-bind/react"; + +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; +} + +export interface TooltipContentFormatters { + narrow?: boolean; // max-width + warning?: boolean; // color + small?: boolean; // font-size + nowrap?: boolean; // white-space + tableView?: boolean; +} + +const defaultProps: Partial = { + usePortal: true, + offset: 10, +}; + +@observer +export class Tooltip 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) { + 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 } = this.props; + const { elem, targetElem } = this; + + if (!elem || !targetElem) { + return; + } + + const positions = new Set([ + ...[preferredPositions ?? []].flat(), + TooltipPosition.RIGHT, + TooltipPosition.BOTTOM, + TooltipPosition.TOP, + TooltipPosition.LEFT, + TooltipPosition.TOP_RIGHT, + TooltipPosition.TOP_LEFT, + TooltipPosition.BOTTOM_RIGHT, + TooltipPosition.BOTTOM_LEFT, + ]); + + // reset position first and get all possible client-rect area for tooltip element + this.setPosition(elem, { left: 0, top: 0 }); + + const selfBounds = elem.getBoundingClientRect(); + const targetBounds = targetElem.getBoundingClientRect(); + const { innerWidth: viewportWidth, innerHeight: viewportHeight } = window; + + // find proper position + for (const pos of positions) { + const { left, top, right, bottom } = this.getPosition(pos, selfBounds, targetBounds); + const fitsToWindow = left >= 0 && top >= 0 && right <= viewportWidth && bottom <= viewportHeight; + + if (fitsToWindow) { + this.activePosition = pos; + this.setPosition(elem, { top, left }); + + return; + } + } + + // apply fallback position if nothing helped from above + const fallbackPosition = Array.from(positions)[0]; + const { left, top } = this.getPosition(fallbackPosition, selfBounds, targetBounds); + + this.activePosition = fallbackPosition; + this.setPosition(elem, { left, top }); + } + + protected setPosition(elem: HTMLDivElement, pos: { left: number; top: number }) { + elem.style.left = `${pos.left}px`; + elem.style.top = `${pos.top}px`; + } + + protected getPosition(position: TooltipPosition, tooltipBounds: DOMRect, targetBounds: DOMRect) { + let left: number; + let top: number; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const offset = this.props.offset!; + 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; + + switch (position) { + case "top": + left = horizontalCenter; + top = topCenter; + break; + case "bottom": + left = horizontalCenter; + top = bottomCenter; + break; + case "left": + top = verticalCenter; + left = targetBounds.left - tooltipBounds.width - offset; + break; + case "right": + top = verticalCenter; + left = targetBounds.right + offset; + break; + case "top_left": + left = targetBounds.left; + top = topCenter; + break; + case "top_right": + left = targetBounds.right - tooltipBounds.width; + top = topCenter; + break; + case "bottom_left": + top = bottomCenter; + left = targetBounds.left; + break; + case "bottom_right": + top = bottomCenter; + left = targetBounds.right - tooltipBounds.width; + break; + default: + throw new TypeError("Invalid props.postition value"); + } + + return { + left, + top, + right: left + tooltipBounds.width, + bottom: top + tooltipBounds.height, + }; + } + + 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" + > + {children} +
+ ); + + if (usePortal) { + return createPortal(tooltip, document.body); + } + + return tooltip; + } +} diff --git a/packages/ui-components/tooltip/src/withTooltip.tsx b/packages/ui-components/tooltip/src/withTooltip.tsx new file mode 100644 index 0000000000..7ff77383a0 --- /dev/null +++ b/packages/ui-components/tooltip/src/withTooltip.tsx @@ -0,0 +1,77 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +// Tooltip decorator for simple composition with other components + +import type { ReactNode } from "react"; +import React, { useState } from "react"; +import type { TooltipProps } from "@k8slens/tooltip"; +import { Tooltip } from "@k8slens/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;