Unverified Commit 50d1bf1d authored by Andrey Azov's avatar Andrey Azov Committed by GitHub
Browse files

Refactor tooltip and zmenu (to PointerBox, Tooltip, and Toolbox) (#270)

Also:
- fix tooltip positioning bug related to window scrolling
- add useRefWithRerender custom hook that makes sure
  that component updates after ref.current has been set
  (useful when rendering of component's children depends on
  existence of refs to other DOM elements).
parent b91198e5
Pipeline #67030 failed with stages
in 9 minutes and 20 seconds
......@@ -4,7 +4,7 @@ import classNames from 'classnames';
import Select from 'src/shared/components/select/Select';
import Input from 'src/shared/components/input/Input';
import Tooltip, { Position } from 'src/shared/components/tooltip/Tooltip';
import Tooltip from 'src/shared/components/tooltip/Tooltip';
import Overlay from 'src/shared/components/overlay/Overlay';
import { ChrLocation } from '../browserState';
......@@ -22,6 +22,7 @@ import {
toggleRegionEditorActive
} from '../browserActions';
import { GenomeKaryotypeItem } from 'src/shared/state/genome/genomeTypes';
import { Position } from 'src/shared/components/pointer-box/PointerBox';
import {
getCommaSeparatedNumber,
......
@import 'src/styles/common';
$zmenuBackgroundColor: $black;
.zmenuWrapper {
position: absolute;
filter: drop-shadow(2px 2px 3px $shadow-color);
}
.zmenu {
position: relative;
padding: 12px 20px;
color: $white;
background-color: $zmenuBackgroundColor;
min-height: 40px;
font-size: 13px;
}
.zmenuTip {
.zmenuAnchor {
position: absolute;
polygon {
fill: $zmenuBackgroundColor;
}
}
.zmenuContentFeature {
margin: 0;
&:not(:last-of-type) {
margin-bottom: 1em;
}
margin-bottom: 1em;
}
.zmenuContentLine {
......@@ -58,3 +36,32 @@ $zmenuBackgroundColor: $black;
cursor: pointer;
color: $blue;
}
.zmenuAppLinks {
display: flex;
}
.zmenuAppButton {
flex: 0 0 auto;
height: 20px;
width: 20px;
margin-left: 16px;
svg {
width: 20px;
height: 20px;
fill: $white;
background-color: $blue;
}
}
.zmenuToggleFooter {
margin-left: auto;
color: $blue;
}
.zmenuFooterContent {
margin-top: 12px;
border-top: 1px solid rgba($grey, 0.3);
padding-top: 12px;
}
......@@ -39,22 +39,4 @@ describe('<Zmenu />', () => {
expect(wrapper.find(ZmenuContent)).toHaveLength(1);
});
});
describe('behaviour', () => {
test('zmenu action is sent to browser on mouse enter', () => {
wrapper
.find('div')
.first()
.simulate('mouseenter');
expect(wrapper.props().onEnter).toHaveBeenCalledTimes(1);
});
test('zmenu action is sent to browser on mouse leave', () => {
wrapper
.find('div')
.first()
.simulate('mouseleave');
expect(wrapper.props().onLeave).toHaveBeenCalledTimes(1);
});
});
});
import React, { useRef } from 'react';
import React from 'react';
import browserMessagingService from 'src/content/app/browser/browser-messaging-service';
import useOutsideClick from 'src/shared/hooks/useOutsideClick';
import useRefWithRerender from 'src/shared/hooks/useRefWithRerender';
import {
Toolbox,
ToolboxPosition,
ToolboxExpandableContent
} from 'src/shared/components/toolbox';
import ZmenuContent from './ZmenuContent';
import styles from './Zmenu.scss';
import { ZmenuData, ZmenuAction, AnchorCoordinates } from './zmenu-types';
import { ZmenuData, ZmenuAction } from './zmenu-types';
const TIP_WIDTH = 18;
const TIP_HEIGHT = 13;
// extra height makes the tip a bit higher and is used to anchor the tip in zmenu body (goes inside the body)
const TIP_EXTRA_HEIGHT = 2;
const TIP_OFFSET = 10;
import styles from './Zmenu.scss';
enum Direction {
LEFT = 'left',
......@@ -26,52 +25,53 @@ export type ZmenuProps = ZmenuData & {
onLeave: (id: string) => void;
};
type InlineStyles = { [key: string]: string | number | undefined };
type TipProps = {
direction: Direction; // where the tip is pointing
style: InlineStyles;
};
type GetInlineStylesParams = {
direction: Direction;
anchorCoordinates: AnchorCoordinates;
};
const Zmenu = (props: ZmenuProps) => {
const anchorRef = useRefWithRerender<HTMLDivElement>(null);
const onOutsideClick = () =>
browserMessagingService.send('bpane', {
id: props.id,
action: ZmenuAction.ACTIVITY_OUTSIDE
});
const zmenuRef = useRef<HTMLDivElement>(null);
useOutsideClick(zmenuRef, onOutsideClick);
const direction = chooseDirection(props);
const inlineStyles = getInlineStyles({
direction,
anchorCoordinates: props.anchor_coordinates
});
const toolboxPosition =
direction === Direction.LEFT ? ToolboxPosition.LEFT : ToolboxPosition.RIGHT;
const mainContent = <ZmenuContent content={props.content} />;
const footerContent = (
<div className={styles.zmenuFooterContent}>Zmenu footer content</div>
);
const anchorStyles = getAnchorInlineStyles(props);
return (
<div
className={styles.zmenuWrapper}
style={inlineStyles.body}
ref={zmenuRef}
onMouseEnter={() => props.onEnter(props.id)}
onMouseLeave={() => props.onLeave(props.id)}
>
<div className={styles.zmenu}>
<ZmenuContent content={props.content} />
</div>
<Tip
direction={getInverseDirection(direction)}
style={inlineStyles.tip}
/>
<div ref={anchorRef} className={styles.zmenuAnchor} style={anchorStyles}>
{anchorRef.current && (
<Toolbox
onOutsideClick={onOutsideClick}
anchor={anchorRef.current}
position={toolboxPosition}
>
<ToolboxExpandableContent
mainContent={mainContent}
footerContent={footerContent}
/>
</Toolbox>
)}
</div>
);
};
const getAnchorInlineStyles = (params: ZmenuProps) => {
const {
anchor_coordinates: { x, y }
} = params;
return {
left: `${x}px`,
top: `${y}px`
};
};
// choose how to position zmenu relative to its anchor point
const chooseDirection = (params: ZmenuProps) => {
const browserElement = params.browserRef.current as HTMLDivElement;
......@@ -80,73 +80,4 @@ const chooseDirection = (params: ZmenuProps) => {
return x > width / 2 ? Direction.LEFT : Direction.RIGHT;
};
const getInlineStyles = (params: GetInlineStylesParams) => {
const { direction, anchorCoordinates } = params;
if (direction === Direction.LEFT) {
return {
body: {
// body is to the left of the anchor point
left: `${anchorCoordinates.x - TIP_HEIGHT}px`,
transform: `translateX(-100%)`,
top: `${anchorCoordinates.y - TIP_OFFSET - TIP_WIDTH / 2}px`
},
tip: {
// tip is on the right side of the body and points to the right
right: `-${TIP_HEIGHT}px`,
top: `${TIP_OFFSET}px`,
width: `${TIP_HEIGHT + TIP_EXTRA_HEIGHT}px`,
height: `${TIP_WIDTH}px`
}
};
} else {
// Direction.RIGHT
return {
body: {
// body is to the right of the anchor point
left: `${anchorCoordinates.x + TIP_HEIGHT}px`,
top: `${anchorCoordinates.y - TIP_OFFSET - TIP_WIDTH / 2}px`
},
tip: {
// tip is on the left side of the body and points to the left
left: `-${TIP_HEIGHT}px`,
top: `${TIP_OFFSET}px`,
width: `${TIP_HEIGHT + TIP_EXTRA_HEIGHT}px`,
height: `${TIP_WIDTH}px`
}
};
}
};
const getInverseDirection = (direction: Direction) => {
if (direction === Direction.LEFT) {
return Direction.RIGHT;
} else {
return Direction.LEFT;
}
};
export const Tip = (props: TipProps) => {
const halfBase = TIP_WIDTH / 2;
let polygonPoints;
const height = TIP_HEIGHT + TIP_EXTRA_HEIGHT;
if (props.direction === Direction.LEFT) {
polygonPoints = `0,${halfBase} ${height},0 ${height},${TIP_WIDTH}`;
} else {
polygonPoints = `0,0 ${height},${halfBase} 0,${TIP_WIDTH}`;
}
return (
<svg
className={styles.zmenuTip}
style={props.style}
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
viewBox={`0 0 ${height} ${TIP_WIDTH}`}
>
<polygon points={polygonPoints} />
</svg>
);
};
export default Zmenu;
import React from 'react';
import { connect } from 'react-redux';
import { push } from 'connected-react-router';
import { isEnvironment, Environment } from 'src/shared/helpers/environment';
import * as urlFor from 'src/shared/helpers/urlHelper';
import {
getBrowserActiveGenomeId,
getBrowserActiveEnsObjectId,
isFocusObjectPositionDefault
} from '../browserSelectors';
import ImageButton from 'src/shared/components/image-button/ImageButton';
import { ToggleButton as ToolboxToggleButton } from 'src/shared/components/toolbox';
import { ReactComponent as BrowserIcon } from 'static/img/launchbar/browser.svg';
import { ReactComponent as EntityViewerIcon } from 'static/img/launchbar/entity-viewer.svg';
import { RootState } from 'src/store';
import styles from './Zmenu.scss';
type Props = {
featureId: string;
activeFeatureId: string | null;
genomeId: string | null;
isInDefaultPosition: boolean;
push: (path: string) => void;
};
const ZmenuAppLinks = (props: Props) => {
if (!isEnvironment([Environment.DEVELOPMENT, Environment.INTERNAL])) {
return null;
}
// FIXME: the row of buttons should be shown only for the gene feature.
// Change this temporary hack to using the "type" field when genome browser
// starts reporting the type of clicked features
// (also, probably move this check in a parent component)
if (!props.featureId.includes(':gene:')) {
return null;
}
const getBrowserLink = () =>
urlFor.browser({ genomeId: props.genomeId, focus: props.featureId });
const getEntityViewerLink = () =>
urlFor.entityViewer({
genomeId: props.genomeId,
entityId: props.featureId
});
const shouldShowBrowserButton =
props.featureId !== props.activeFeatureId || !props.isInDefaultPosition;
return (
<div className={styles.zmenuAppLinks}>
<span>View in</span>
{shouldShowBrowserButton && (
<ImageButton
className={styles.zmenuAppButton}
image={BrowserIcon}
onClick={() => props.push(getBrowserLink())}
/>
)}
<ImageButton
className={styles.zmenuAppButton}
image={EntityViewerIcon}
onClick={() => props.push(getEntityViewerLink())}
/>
<ToolboxToggleButton
className={styles.zmenuToggleFooter}
openElement={<span>Download</span>}
/>
</div>
);
};
const mapStateToProps = (state: RootState) => ({
genomeId: getBrowserActiveGenomeId(state),
activeFeatureId: getBrowserActiveEnsObjectId(state),
isInDefaultPosition: isFocusObjectPositionDefault(state)
});
const mapDispatchToProps = {
push
};
export default connect(mapStateToProps, mapDispatchToProps)(ZmenuAppLinks);
......@@ -18,6 +18,8 @@ import {
} from './zmenu-types';
import { createZmenuContent } from 'tests/fixtures/browser';
jest.mock('./ZmenuAppLinks', () => () => <div>ZmenuAppLinks</div>);
describe('<ZmenuContent />', () => {
afterEach(() => {
jest.resetAllMocks();
......@@ -38,7 +40,7 @@ describe('<ZmenuContent />', () => {
});
describe('rendering', () => {
test('renders the correct zmenu content information', () => {
it('renders the correct zmenu content information', () => {
const firstLineData = wrapper
.find(ZmenuContentLine)
.first()
......@@ -57,7 +59,7 @@ describe('<ZmenuContent />', () => {
});
describe('behaviour', () => {
test('changes focus feature when feature link is clicked', () => {
it('changes focus feature when feature link is clicked', () => {
const props: ZmenuContentItemProps = {
id: faker.lorem.words(),
markup: [Markup.FOCUS],
......
......@@ -4,6 +4,8 @@ import { connect } from 'react-redux';
import { changeFocusObject } from 'src/content/app/browser/browserActions';
import ZmenuAppLinks from './ZmenuAppLinks';
import {
ZmenuContentFeature as ZmenuContentFeatureType,
ZmenuContentLine as ZmenuContentLineType,
......@@ -41,11 +43,13 @@ export type ZmenuContentItemProps = ZmenuContentItemType & {
export const ZmenuContent = (props: ZmenuContentProps) => {
const features = props.content;
const renderedContent = features.map((feature) => (
<ZmenuContentFeature
key={feature.id}
id={feature.id}
lines={feature.lines}
/>
<div key={feature.id}>
<ZmenuContentFeature
id={feature.id}
lines={feature.lines}
/>
<ZmenuAppLinks featureId={feature.id} />
</div>
));
return <>{renderedContent}</>;
};
......
.pointerBox {
position: absolute;
z-index: 1000;
width: max-content; // not supported in IE or non-webkit Edge
box-sizing: content-box; // Safari doesn't seem to respect "width: max-content" if box-sizing is border-box
padding: 6px;
}
// Class applied to pointer box while the code is finding appropriate position for it.
// Pointer box should be invisible, but have width and height required for calculations.
// It should also not change dimensions of the page (and cause sliders to appear);
// hence it is placed in top left corner of the screen with position: fixed
.invisible {
visibility: hidden;
position: fixed;
top: 0;
left: 0;
}
.pointer {
position: absolute;
mix-blend-mode: lighten;
}
import React from 'react';
import { mount } from 'enzyme';
import faker from 'faker';
import PointerBox from './PointerBox';
describe('<PointerBox />', () => {
let anchor: HTMLDivElement;
beforeEach(() => {
anchor = document.createElement('div');
document.body.appendChild(anchor);
});
afterEach(() => {
document.querySelector('.pointerBox')?.remove();
});
it('renders at the top level of document.body by default', () => {
mount(<PointerBox anchor={anchor}>{faker.lorem.paragraph()}</PointerBox>);
expect(document.querySelectorAll('body > .pointerBox').length).toBe(1);
});
it('can render inside anchor element', () => {
mount(
<PointerBox anchor={anchor} renderInsideAnchor={true}>
{faker.lorem.paragraph()}
</PointerBox>
);
expect(document.querySelectorAll('body > .pointerBox').length).toBe(0);
expect(anchor.querySelector('.pointerBox')).toBeTruthy();
});
});
import React, { ReactNode, useRef, useEffect, useState } from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
import once from 'lodash/once';
import noop from 'lodash/noop';
import windowService from 'src/services/window-service';
import useOutsideClick from 'src/shared/hooks/useOutsideClick';
import { findOptimalPosition } from './pointer-box-helper';
import {
getStylesForRenderingIntoBody,
getStylesForRenderingIntoAnchor
} from './pointer-box-inline-styles';
import {
POINTER_WIDTH,
POINTER_HEIGHT,
POINTER_OFFSET
} from './pointer-box-constants';
import { Position } from './pointer-box-types';
import styles from './PointerBox.scss';
type InlineStyles = { [key: string]: string | number | undefined };
export type InlineStylesState = {
boxStyles: InlineStyles;
pointerStyles: InlineStyles;
};
type PointerProps = {
style: InlineStyles;
className?: string;
width: number;
height: number;
};
export type PointerBoxProps = {
position: Position;
anchor: HTMLElement;
container?: HTMLElement | null; // area within which the box should try to position itself; defaults to window if null
autoAdjust: boolean; // whether to adjust pointer box position so as not to extend beyond screen bounds
renderInsideAnchor: boolean; // whether to render PointerBox inside the anchor (which should have position: relative to display it properly); renders to body if false
pointerWidth: number;
pointerHeight: number;
pointerOffset: number;
classNames?: {
box?: string;
pointer?: string;
};
children: ReactNode;
onMouseEnter: () => void;
onMouseLeave: () => void;
onOutsideClick: () => void;
onClose: () => void;
};
const handleClickInside = (e: React.MouseEvent | React.TouchEvent) => {
e.preventDefault();
e.stopPropagation(); // this works within React's event system
e.nativeEvent.stopImmediatePropagation(); // also prevent propagation to DOM elements outside of React (e.g. to document)
};
const PointerBox = (props: PointerBoxProps) => {
const [isPositioning, setIsPositioning] = useState(props.autoAdjust);
const positionRef = useRef<Position | null>(props.position);
const anchorRectRef = useRef<DOMRect | null>(null);
const [inlineStyles, setInlineStyles] = useState<InlineStylesState>({
boxStyles: {},
pointerStyles: {}
});
const pointerBoxRef = useRef<HTMLDivElement>(null);
useOutsideClick(pointerBoxRef, props.onOutsideClick);