Skip to content

Commit

Permalink
Merge pull request #51 from stoplightio/feat/SL-1331/controlled-mode-…
Browse files Browse the repository at this point in the history
…popup

[SL-1331] Popup controlled mode
  • Loading branch information
P0lip authored Jan 23, 2019
2 parents 94470e2 + 26cc2e4 commit 52b9eaa
Show file tree
Hide file tree
Showing 7 changed files with 244 additions and 248 deletions.
146 changes: 146 additions & 0 deletions src/Popup/Popup.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import * as React from 'react';

import { useWindowResize } from '../hooks/useWindowResize';
import { useTheme } from '../theme';
import { PopupContent } from './PopupContent';
import { IPopupDefaultProps, IPopupProps } from './types';
import { calculateStyles, getDefaultStyle } from './utils';

export { IPopupProps, IPopupDefaultProps };

export interface IPopup extends IPopupProps {}

export const Popup: React.FunctionComponent<IPopup> = props => {
const { hideDelay, width, offset, posX, posY, show = false } = props;

const theme = useTheme();

const controlled = 'show' in props;
const triggerRef = React.useRef<HTMLElement>(null);
const contentRef = React.useRef<HTMLDivElement>(null);
const [visibility, setVisibility] = React.useState<boolean>(false);
const isVisible = controlled ? show : visibility;
const lastResizeTimestamp = useWindowResize();
const [style, setStyle] = React.useState<React.CSSProperties>({});
let isOverTrigger: boolean = false;
let isOverContent: boolean = false;
let willHide: NodeJS.Timer | number | null = null;

const repaint = React.useCallback(
() => {
if (isVisible) {
setStyle({
...getDefaultStyle(props),
...calculateStyles(triggerRef, contentRef, props),
});
}
},
[triggerRef.current, contentRef.current, width, offset, posX, posY, isVisible]
);

if (typeof window !== 'undefined') {
React.useEffect(repaint, [lastResizeTimestamp, contentRef.current]);
}

const showPopup = React.useCallback(
() => {
if (controlled) return;
if (willHide !== null) {
clearTimeout(willHide as number);
willHide = null;
}

setVisibility(true);
},
[willHide, isVisible, controlled]
);

const hidePopup = React.useCallback(
() => {
if (willHide !== null || controlled) {
return;
}

willHide = setTimeout(() => {
isOverTrigger = false;
isOverContent = false;
setVisibility(false);
}, hideDelay);
},
[willHide, isVisible, controlled]
);

const { renderTrigger, renderContent } = props;

const funcs = {
isVisible,
showPopup,
hidePopup,
};

const handleMouseEnter = React.useCallback<React.MouseEventHandler<HTMLElement>>(
({ target }) => {
if (target === triggerRef.current) {
isOverTrigger = true;
} else if (target === contentRef.current) {
isOverContent = true;
}

showPopup();
},
[triggerRef.current, contentRef.current, isVisible]
);

const handleMouseLeave = React.useCallback<React.MouseEventHandler<HTMLElement>>(
({ target }) => {
if (target === triggerRef.current) {
isOverTrigger = false;
} else if (target === contentRef.current) {
isOverContent = false;
}

if (isVisible && !isOverTrigger && !isOverContent) {
hidePopup();
}
},
[triggerRef.current, contentRef.current, isVisible]
);

return (
<>
{React.cloneElement(
renderTrigger({
...funcs,
isOver: isOverTrigger,
}),
{
ref: triggerRef,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
}
)}
{isVisible && (
<PopupContent
ref={contentRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={style}
repaint={repaint}
>
{renderContent({
...funcs,
isOver: isOverContent,
theme,
})}
</PopupContent>
)}
</>
);
};

Popup.defaultProps = {
padding: 15,
hideDelay: 200,
posX: 'left',
posY: 'top',
} as IPopupDefaultProps;
2 changes: 1 addition & 1 deletion src/Popup/PopupContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { IPopupContentProps } from './types';
export const PopupContent = React.forwardRef<HTMLDivElement, IPopupContentProps>((props, ref) => {
const { children, onMouseEnter, onMouseLeave, repaint, style } = props;

React.useEffect(repaint);
React.useEffect(repaint, []);

return (
<Portal>
Expand Down
134 changes: 1 addition & 133 deletions src/Popup/index.tsx
Original file line number Diff line number Diff line change
@@ -1,133 +1 @@
import * as React from 'react';

import { useWindowResize } from '../hooks/useWindowResize';
import { useTheme } from '../theme';
import { PopupContent } from './PopupContent';
import { IPopupDefaultProps, IPopupProps } from './types';
import { calculateStyles, getDefaultStyle } from './utils';

export { IPopupProps, IPopupDefaultProps };

export interface IPopup extends IPopupProps {}

export const Popup: React.FunctionComponent<IPopup> = props => {
const { hideDelay, width, offset, posX, posY } = props;

const theme = useTheme();

const triggerRef = React.useRef<HTMLElement>(null);
const contentRef = React.createRef<HTMLDivElement>();
const [isVisible, setVisibility] = React.useState<boolean>(false);
const lastResizeTimestamp = useWindowResize();
let lastRepaintTimestamp = 0;
const [style, setStyle] = React.useState<React.CSSProperties>({});
let isOverTrigger: boolean = false;
let isOverContent: boolean = false;
let willHide: NodeJS.Timer | number | null = null;

const repaint = React.useCallback(
() => {
if (isVisible) {
lastRepaintTimestamp = Date.now();
setStyle({
...getDefaultStyle(props),
...calculateStyles(triggerRef, contentRef, props),
});
}
},
[width, offset, posX, posY, contentRef, lastRepaintTimestamp]
);

if (typeof window !== 'undefined') {
React.useEffect(repaint, [lastResizeTimestamp]);
}

const showPopup = () => {
if (willHide !== null) {
clearTimeout(willHide as number);
willHide = null;
}

setVisibility(true);
};

const hidePopup = () => {
if (willHide !== null) {
return;
}

willHide = setTimeout(() => {
isOverTrigger = false;
isOverContent = false;
setVisibility(false);
}, hideDelay);
};

const { renderTrigger, renderContent } = props;

const funcs = {
isVisible,
showPopup,
hidePopup,
};

const handleMouseEnter = ({ target }: React.SyntheticEvent<HTMLElement>) => {
if (target === triggerRef.current) {
isOverTrigger = true;
} else if (target === contentRef.current) {
isOverContent = true;
}

showPopup();
};

const handleMouseLeave = ({ target }: React.SyntheticEvent<HTMLElement>) => {
if (target === triggerRef.current) {
isOverTrigger = false;
} else if (target === contentRef.current) {
isOverContent = false;
}

if (isVisible && !isOverTrigger && !isOverContent) {
hidePopup();
}
};

return (
<>
{React.cloneElement(
renderTrigger({
...funcs,
isOver: isOverTrigger,
}),
{
ref: triggerRef,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
}
)}
{isVisible && (
<PopupContent
ref={contentRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={style}
repaint={repaint}
>
{renderContent({
...funcs,
isOver: isOverContent,
theme,
})}
</PopupContent>
)}
</>
);
};

Popup.defaultProps = {
padding: 15,
hideDelay: 200,
posX: 'left',
posY: 'top',
} as IPopupDefaultProps;
export * from './Popup';
1 change: 1 addition & 0 deletions src/Popup/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export interface IPopupProps extends IPopupPosition {
hideDelay?: number; // how long popup will show for after user mouses out
renderTrigger: PopupTriggerRenderer;
renderContent: PopupContentRenderer;
show?: boolean;
}

export interface IPopupDefaultProps {
Expand Down
2 changes: 1 addition & 1 deletion src/Popup/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const calculateStyles = (
content: RefObject<HTMLDivElement>,
props: IPopupProps
): CSSProperties | null => {
if (!trigger || !content || !trigger.current || !content.current) return null;
if (!trigger.current || !content.current) return null;

const offset = getOffset(props.offset);

Expand Down
14 changes: 12 additions & 2 deletions src/__stories__/Misc/Popup.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/* @jsx jsx */

import { jsx } from '@emotion/core';
import { NumberOptions, withKnobs } from '@storybook/addon-knobs';
import { boolean, NumberOptions, withKnobs } from '@storybook/addon-knobs';
import { number, select, text } from '@storybook/addon-knobs/react';
import { storiesOf } from '@storybook/react';

import { Box, Icon, Popup } from '../..';

export const popupKnobs = (tabName = 'Popup'): any => ({
const TAB_NAME = 'Popup';

export const popupKnobs = (tabName = TAB_NAME): any => ({
posX: select('posX', ['left', 'center', 'right'], 'left', tabName),
posY: select('posY', ['top', 'center', 'bottom'], 'top', tabName),
offset: {
Expand Down Expand Up @@ -47,4 +49,12 @@ storiesOf('Miscellaneous:Popup', module)
}}
renderContent={() => <Box as="span">Globe</Box>}
/>
))
.add('with controlled mode', () => (
<Popup
{...popupKnobs()}
show={boolean('show', false, TAB_NAME)}
renderTrigger={() => <Box as="span">I am controlled, so hovering is no-op!</Box>}
renderContent={() => <Box>{text('content', 'here is the popup content')}</Box>}
/>
));
Loading

0 comments on commit 52b9eaa

Please sign in to comment.