1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

tooltip refactoring -- part 1

Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
Roman 2020-07-28 16:57:24 +03:00
parent 4087cf025e
commit f2803abcb6
13 changed files with 228 additions and 272 deletions

View File

@ -2,18 +2,21 @@
--size: 37px;
position: relative;
opacity: .55;
border-radius: $radius;
padding: $radius;
user-select: none;
cursor: pointer;
&.active, &.interactive:hover {
opacity: 1;
background-color: #fff;
img {
opacity: 1;
}
}
> img {
img {
opacity: .55;
width: var(--size);
height: var(--size);
}

View File

@ -7,7 +7,7 @@ import { Hashicon } from "@emeraldpay/hashicon-react";
import { Cluster } from "../../../main/cluster";
import { cssNames, IClassName } from "../../utils";
import { Badge } from "../badge";
import { Tooltip, TooltipContent } from "../tooltip";
import { Tooltip } from "../tooltip";
interface Props extends DOMAttributes<HTMLElement> {
cluster: Cluster;
@ -44,9 +44,7 @@ export class ClusterIcon extends React.Component<Props> {
return (
<div {...elemProps} className={className} id={clusterIconId}>
{showTooltip && (
<Tooltip htmlFor={clusterIconId} position={{ right: true }}>
<TooltipContent nowrap>{clusterName}</TooltipContent>
</Tooltip>
<Tooltip targetId={clusterIconId}>{clusterName}</Tooltip>
)}
{icon && <img src={icon} alt={clusterName}/>}
{!icon && <Hashicon value={clusterName} options={options}/>}

View File

@ -1,13 +1,13 @@
import "./events.scss";
import React from "react";
import React, { Fragment } from "react";
import { observer } from "mobx-react";
import { MainLayout } from "../layout/main-layout";
import { eventStore } from "./event.store";
import { KubeObjectListLayout, KubeObjectListLayoutProps } from "../kube-object";
import { Trans } from "@lingui/macro";
import { KubeEvent } from "../../api/endpoints/events.api";
import { Tooltip, TooltipContent } from "../tooltip";
import { Tooltip } from "../tooltip";
import { Link } from "react-router-dom";
import { cssNames, IClassName, stopPropagation } from "../../utils";
import { Icon } from "../icon";
@ -90,18 +90,14 @@ export class Events extends React.Component<Props> {
const detailsUrl = getDetailsUrl(lookupApiLink(involvedObject, event));
return [
{
className: {
warning: isWarning
},
className: { warning: isWarning },
title: (
<>
<Fragment>
<span id={tooltipId}>{message}</span>
<Tooltip htmlFor={tooltipId} following>
<TooltipContent narrow warning={isWarning}>
{message}
</TooltipContent>
<Tooltip targetId={tooltipId} formatters={{ narrow: true, warning: isWarning }}>
{message}
</Tooltip>
</>
</Fragment>
)
},
event.getNs(),

View File

@ -2,7 +2,6 @@ import "./kube-event-icon.scss";
import React from "react";
import { Icon } from "../icon";
import { TooltipContent } from "../tooltip";
import { KubeObject } from "../../api/kube-object";
import { eventStore } from "./event.store";
import { cssNames } from "../../utils";
@ -34,15 +33,17 @@ export class KubeEventIcon extends React.Component<Props> {
<Icon
material="warning"
className={cssNames("KubeEventIcon", { warning: event.isWarning() })}
tooltip={(
<TooltipContent className="KubeEventTooltip">
{event.message}
<div className="age">
<Icon material="access_time"/>
{event.getAge(undefined, undefined, true)}
tooltip={{
children: (
<div className="KubeEventTooltip">
<div className="msg">{event.message}</div>
<div className="age">
<Icon material="access_time"/>
{event.getAge(undefined, undefined, true)}
</div>
</div>
</TooltipContent>
)}
)
}}
/>
)
}

View File

@ -7,7 +7,6 @@ import { disposeOnUnmount, observer } from "mobx-react";
import { Trans } from "@lingui/macro";
import { DrawerItem, DrawerItemLabels } from "../drawer";
import { Badge } from "../badge";
import { TooltipContent } from "../tooltip";
import { nodesStore } from "./nodes.store";
import { ResourceMetrics } from "../resource-metrics";
import { podsStore } from "../+workloads-pods/pods.store";
@ -127,16 +126,17 @@ export class NodeDetails extends React.Component<Props> {
key={type}
label={type}
className={kebabCase(type)}
tooltip={
<TooltipContent tableView>
{Object.entries(condition).map(([key, value]) =>
<div key={key} className="flex gaps align-center">
<div className="name">{upperFirst(key)}</div>
<div className="value">{value}</div>
</div>
)}
</TooltipContent>
}
tooltip={{
formatters: {
tableView: true,
},
children: Object.entries(condition).map(([key, value]) =>
<div key={key} className="flex gaps align-center">
<div className="name">{upperFirst(key)}</div>
<div className="value">{value}</div>
</div>
)
}}
/>
)
})

View File

@ -15,7 +15,7 @@ import { NodeMenu } from "./node-menu";
import { LineProgress } from "../line-progress";
import { _i18n } from "../../i18n";
import { bytesToUnits } from "../../utils/convertMemory";
import { Tooltip, TooltipContent } from "../tooltip";
import { Tooltip } from "../tooltip";
import kebabCase from "lodash/kebabCase";
import upperFirst from "lodash/upperFirst";
import { apiManager } from "../../api/api-manager";
@ -101,15 +101,13 @@ export class Nodes extends React.Component<Props> {
return (
<div key={type} id={tooltipId} className={cssNames("condition", kebabCase(type))}>
{type}
<Tooltip htmlFor={tooltipId} following>
<TooltipContent tableView>
{Object.entries(condition).map(([key, value]) =>
<div key={key} className="flex gaps align-center">
<div className="name">{upperFirst(key)}</div>
<div className="value">{value}</div>
</div>
)}
</TooltipContent>
<Tooltip targetId={tooltipId} formatters={{ tableView: true }}>
{Object.entries(condition).map(([key, value]) =>
<div key={key} className="flex gaps align-center">
<div className="name">{upperFirst(key)}</div>
<div className="value">{value}</div>
</div>
)}
</Tooltip>
</div>)
})
@ -162,7 +160,7 @@ export class Nodes extends React.Component<Props> {
this.renderDiskUsage(node),
<>
<span id={tooltipId}>{node.getTaints().length}</span>
<Tooltip htmlFor={tooltipId} style={{ whiteSpace: "pre-line" }}>
<Tooltip targetId={tooltipId} style={{ whiteSpace: "pre-line" }}>
{node.getTaints().map(({ key, effect }) => `${key}: ${effect}`).join("\n")}
</Tooltip>
</>,

View File

@ -14,7 +14,6 @@ import { Pod, podsApi } from "../../api/endpoints";
import { PodMenu } from "./pod-menu";
import { StatusBrick } from "../status-brick";
import { cssNames, stopPropagation } from "../../utils";
import { TooltipContent } from "../tooltip";
import { KubeEventIcon } from "../+events/kube-event-icon";
import { getDetailsUrl } from "../../navigation";
import toPairs from "lodash/toPairs";
@ -42,26 +41,29 @@ export class Pods extends React.Component<Props> {
renderContainersStatus(pod: Pod) {
return pod.getContainerStatuses().map(containerStatus => {
const { name, state, ready } = containerStatus;
const tooltip = (
<TooltipContent tableView>
{Object.keys(state).map(status => (
<Fragment key={status}>
<div className="title">
{name} <span className="text-secondary">({status}{ready ? ", ready" : ""})</span>
</div>
{toPairs(state[status]).map(([name, value]) => (
<div key={name} className="flex gaps align-center">
<div className="name">{startCase(name)}</div>
<div className="value">{value}</div>
</div>
))}
</Fragment>
))}
</TooltipContent>
);
return (
<Fragment key={name}>
<StatusBrick className={cssNames(state, { ready })} tooltip={tooltip}/>
<StatusBrick
className={cssNames(state, { ready })}
tooltip={{
formatters: {
tableView: true
},
children: Object.keys(state).map(status => (
<Fragment key={status}>
<div className="title">
{name} <span className="text-secondary">({status}{ready ? ", ready" : ""})</span>
</div>
{toPairs(state[status]).map(([name, value]) => (
<div key={name} className="flex gaps align-center">
<div className="name">{startCase(name)}</div>
<div className="value">{value}</div>
</div>
))}
</Fragment>
))
}}
/>
</Fragment>
)
});

View File

@ -9,7 +9,6 @@ export type AnimateName = "opacity" | "slide-right" | "opacity-scale" | string;
export interface AnimateProps {
name?: AnimateName; // predefined names in css
enter?: boolean;
enabled?: boolean;
onEnter?: () => void;
onLeave?: () => void;
}
@ -21,7 +20,6 @@ export class Animate extends React.Component<AnimateProps> {
static defaultProps: AnimateProps = {
name: "opacity",
enter: true,
enabled: true,
onEnter: noop,
onLeave: noop,
};
@ -80,10 +78,7 @@ export class Animate extends React.Component<AnimateProps> {
}
render() {
const { name, enabled, children } = this.props;
if (!enabled) {
return children;
}
const { name } = this.props;
const contentElem = this.contentElem;
return React.cloneElement(contentElem, {
className: cssNames("Animate", name, contentElem.props.className, this.statusClassName),

View File

@ -17,7 +17,7 @@ import { navigate, navigation } from "../../navigation";
import { addClusterURL } from "../+add-cluster";
import { clusterSettingsURL } from "../+cluster-settings";
import { landingURL } from "../+landing-page";
import { Tooltip, TooltipContent } from "../tooltip";
import { Tooltip } from "../tooltip";
import { ConfirmDialog } from "../confirm-dialog";
import { clusterIpc } from "../../../common/cluster-ipc";
import { clusterStatusURL } from "./cluster-status.route";
@ -118,10 +118,8 @@ export class ClustersMenu extends React.Component<Props> {
})}
</div>
<div className="add-cluster" onClick={this.addCluster}>
<Tooltip htmlFor="add-cluster-icon" position={{ right: true }}>
<TooltipContent nowrap>
<Trans>Add Cluster</Trans>
</TooltipContent>
<Tooltip targetId="add-cluster-icon">
<Trans>Add Cluster</Trans>
</Tooltip>
<Icon big material="add" id="add-cluster-icon"/>
{newContexts.size > 0 && (

View File

@ -28,7 +28,6 @@ import { CrdList, crdResourcesRoute, crdRoute, crdURL } from "../+custom-resourc
import { CustomResources } from "../+custom-resources/custom-resources";
import { navigation } from "../../navigation";
import { isAllowedResource } from "../../api/rbac"
import { TooltipContent } from "../tooltip";
const SidebarContext = React.createContext<SidebarContextValue>({ pinned: false });
type SidebarContextValue = {
@ -83,17 +82,9 @@ export class Sidebar extends React.Component<Props> {
</NavLink>
<Icon
className="pin-icon"
tooltip={<Trans>Compact view</Trans>}
material={isPinned ? "keyboard_arrow_left" : "keyboard_arrow_right"}
onClick={toggle}
tooltip={{
following: false,
position: { right: true },
children: (
<TooltipContent nowrap>
<Trans>Compact view</Trans>
</TooltipContent>
)
}}
focusable={false}
/>
</div>

View File

@ -1,101 +1,76 @@
.Tooltip {
--tooltip-bgc: #{$contentColor};
--tooltip-color: #{$textColorSecondary};
--tooltip-margin: #{$padding / 2 $padding / 3};
position: absolute;
background: var(--tooltip-bgc);
// use positioning relative to viewport (window)
// https://developer.mozilla.org/en-US/docs/Web/CSS/position
position: fixed;
margin: 0 !important;
background: $contentColor;
font-size: small;
font-weight: normal;
border: 1px solid $borderColor;
border-radius: $radius;
color: var(--tooltip-color);
margin: var(--tooltip-margin);
color: $textColorSecondary;
white-space: normal;
padding: .5em;
text-align: center;
transition-delay: 100ms; // delay for .Animate
pointer-events: none;
z-index: 1000;
transition: opacity 150ms 25ms ease-in-out;
* {
white-space: normal;
&.hidden {
opacity: 0;
visibility: hidden;
}
&:empty {
display: none;
}
&.following {
position: fixed;
}
&:not(.following) {
margin: #{$margin};
&.left {
right: 100%;
&.formatter {
&.nowrap {
&, * {
white-space: nowrap;
}
}
&.right {
left: 100%;
}
&.top {
bottom: 100%;
}
&.bottom {
top: 100%;
}
}
}
.TooltipContent {
&.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: $colorError;
}
&.tableView {
min-width: 200px;
> :not(:last-child) {
margin-bottom: var(--flex-gap);
}
.title {
color: $textColorAccent;
text-align: center;
}
.name {
color: $textColorAccent;
text-align: right;
flex: 0 0 35%;
}
.value {
text-align: left;
&.narrow {
max-width: 300px;
word-break: break-word;
text-overflow: ellipsis;
word-wrap: break-word;
text-align: left;
}
&.small {
font-size: $font-size-small;
}
&.warning {
color: $colorError;
}
&.tableView {
min-width: 200px;
> :not(:last-child) {
margin-bottom: var(--flex-gap);
}
.title {
color: $textColorAccent;
text-align: center;
}
.name {
color: $textColorAccent;
text-align: right;
flex: 0 0 35%;
}
.value {
text-align: left;
max-width: 300px;
word-break: break-word;
}
}
}
}

View File

@ -2,109 +2,141 @@ import './tooltip.scss'
import React from "react"
import { observer } from "mobx-react";
import { autobind, cssNames, IClassName } from "../../utils";
import { observable } from "mobx";
import { createPortal } from "react-dom"
import { autobind, cssNames } from "../../utils";
import { Animate } from "../animate";
// fixme: refactor -- better positioning + remove "flying effect"
// todo: always render outside of parent element ("overflow: auto" should not affect tooltip)
export type TooltipPosition = "top" | "left" | "right" | "bottom";
export interface TooltipProps {
htmlFor: string;
className?: string;
position?: Position;
useAnimation?: boolean;
following?: boolean; // tooltip is following mouse position
targetId: string; // "id" of target html-element to bind
visible?: boolean;
offset?: number; // px
position?: TooltipPosition;
className?: IClassName;
formatters?: TooltipContentFormatters;
style?: React.CSSProperties;
children?: React.ReactNode;
}
interface Position {
left?: boolean;
right?: boolean;
top?: boolean;
bottom?: boolean;
export interface TooltipContentFormatters {
narrow?: boolean; // max-width
warning?: boolean; // color
small?: boolean; // font-size
nowrap?: boolean; // white-space
tableView?: boolean;
}
const defaultProps: Partial<TooltipProps> = {
useAnimation: true,
position: {
bottom: true,
}
};
offset: 10,
}
@observer
export class Tooltip extends React.Component<TooltipProps> {
static defaultProps = defaultProps as object;
public anchor: HTMLElement;
public elem: HTMLElement;
@observable.ref elem: HTMLElement;
@observable activePosition: TooltipPosition;
@observable isVisible = !!this.props.visible;
@observable isVisible = false;
get targetElem(): HTMLElement {
return document.getElementById(this.props.targetId)
}
componentDidMount() {
const { htmlFor } = this.props;
this.anchor = htmlFor ? document.getElementById(htmlFor) : this.elem.parentElement;
if (this.anchor) {
if (window.getComputedStyle(this.anchor).position === "static") {
this.anchor.style.position = "relative"
}
this.anchor.addEventListener("mouseenter", this.onMouseEnter);
this.anchor.addEventListener("mouseleave", this.onMouseLeave);
this.anchor.addEventListener("mousemove", this.onMouseMove);
}
this.targetElem.addEventListener("mouseenter", this.onEnterTarget)
this.targetElem.addEventListener("mouseleave", this.onLeaveTarget)
}
componentWillUnmount() {
if (this.anchor) {
this.anchor.removeEventListener("mouseenter", this.onMouseEnter);
this.anchor.removeEventListener("mouseleave", this.onMouseLeave);
this.anchor.removeEventListener("mousemove", this.onMouseMove);
}
this.targetElem.removeEventListener("mouseenter", this.onEnterTarget)
this.targetElem.removeEventListener("mouseleave", this.onLeaveTarget)
}
@autobind()
onMouseEnter(evt: MouseEvent) {
protected onEnterTarget(evt: MouseEvent) {
this.isVisible = true;
this.onMouseMove(evt);
this.refreshPosition();
}
@autobind()
onMouseLeave(evt: MouseEvent) {
protected onLeaveTarget(evt: MouseEvent) {
this.isVisible = false;
}
@autobind()
onMouseMove(evt: MouseEvent) {
if (!this.props.following) {
return;
refreshPosition() {
const { position } = this.props;
const { elem, targetElem } = this;
let allPositions: TooltipPosition[] = ["right", "bottom", "top", "left"];
if (allPositions.includes(position)) {
allPositions = [
position, // put first as priority side for positioning
...allPositions.filter(pos => pos !== position),
];
}
const offsetX = -10;
const offsetY = 10;
const { clientX, clientY } = evt;
const { innerWidth, innerHeight } = window;
// reset position first and get all possible client-rect area for tooltip element
this.setPosition({ left: 0, top: 0 });
const initialPos: Partial<CSSStyleDeclaration> = {
top: "auto",
left: "auto",
right: (innerWidth - clientX + offsetX) + "px",
bottom: (innerHeight - clientY + offsetY) + "px",
}
const selfBounds = elem.getBoundingClientRect();
const targetBounds = targetElem.getBoundingClientRect();
const { innerWidth: viewportWidth, innerHeight: viewportHeight } = window;
Object.assign(this.elem.style, initialPos);
// find proper position
this.activePosition = null;
for (const pos of allPositions) {
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({ top, left });
break;
}
}
if (!this.activePosition) {
const { left, top } = this.getPosition(allPositions[0], selfBounds, targetBounds)
this.activePosition = allPositions[0];
this.setPosition({ left, top });
}
}
// correct position if not fits to viewport
const { left, top } = this.elem.getBoundingClientRect();
if (left < 0) {
this.elem.style.left = clientX + offsetX + "px";
this.elem.style.right = "auto"
}
if (top < 0) {
this.elem.style.top = clientY + offsetY + "px";
this.elem.style.bottom = "auto"
protected setPosition(pos: { left: number, top: number }) {
const elemStyle = this.elem.style;
elemStyle.left = pos.left + "px"
elemStyle.top = pos.top + "px"
}
protected getPosition(position: TooltipPosition, selfBounds: DOMRect, targetBounds: DOMRect) {
let left: number
let top: number
const offset = this.props.offset;
const horizontalCenter = targetBounds.left + (targetBounds.width - selfBounds.width) / 2;
const verticalCenter = targetBounds.top + (targetBounds.height - selfBounds.height) / 2;
switch (position) {
case "top":
left = horizontalCenter;
top = targetBounds.top - selfBounds.height - offset;
break;
case "bottom":
left = horizontalCenter;
top = targetBounds.bottom + offset;
break;
case "left":
top = verticalCenter;
left = targetBounds.left - selfBounds.width - offset;
break;
case "right":
top = verticalCenter;
left = targetBounds.right + offset;
break;
}
return {
left,
top,
right: left + selfBounds.width,
bottom: top + selfBounds.height,
};
}
@autobind()
@ -113,40 +145,15 @@ export class Tooltip extends React.Component<TooltipProps> {
}
render() {
const { isVisible } = this;
const { useAnimation, position, following, style, children } = this.props;
let { className } = this.props;
className = cssNames('Tooltip', position, { following }, className);
const tooltip = (
<Animate enter={isVisible} enabled={useAnimation}>
<div className={className} ref={this.bindRef} style={style}>
{children}
</div>
</Animate>
);
if (following) {
return createPortal(tooltip, document.body);
}
return tooltip;
}
}
interface TooltipContentProps {
className?: string;
narrow?: boolean; // max-width
warning?: boolean; // color
small?: boolean; // font-size
nowrap?: boolean; // white-space
tableView?: boolean;
}
export class TooltipContent extends React.Component<TooltipContentProps> {
render() {
const { className, children, ...modifiers } = this.props;
const { style, formatters, position, children } = this.props;
const className = cssNames("Tooltip", this.props.className, formatters, this.activePosition, {
hidden: !this.isVisible,
formatter: !!formatters,
});
return (
<div className={cssNames("TooltipContent", className, modifiers)}>
<div className={className} style={style} ref={this.bindRef}>
{children}
</div>
)
);
}
}

View File

@ -7,7 +7,7 @@ import { isReactNode } from "../../utils/isReactNode";
import uniqueId from "lodash/uniqueId"
export interface TooltipDecoratorProps {
tooltip?: ReactNode | Omit<TooltipProps, "htmlFor">;
tooltip?: ReactNode | Omit<TooltipProps, "targetId">;
}
export function withTooltip<T extends React.ComponentType<any>>(Target: T): T {
@ -18,20 +18,12 @@ export function withTooltip<T extends React.ComponentType<any>>(Target: T): T {
render() {
const { tooltip, ...targetProps } = this.props;
if (tooltip) {
const tooltipId = targetProps.id || this.tooltipId;
const tooltipProps: TooltipProps = {
htmlFor: tooltipId,
following: true,
targetId: tooltipId,
...(isReactNode(tooltip) ? { children: tooltip } : tooltip),
};
if (!tooltipProps.following) {
targetProps.style = {
position: "relative",
...(targetProps.style || {})
}
}
targetProps.id = tooltipId;
targetProps.children = (
<>