Skip to content

Commit

Permalink
Merge pull request #12 from stoplightio/SL-142/popup
Browse files Browse the repository at this point in the history
SL-142/popup
  • Loading branch information
P0lip authored Nov 27, 2018
2 parents e57f532 + 6bf60ca commit b3d9c9b
Show file tree
Hide file tree
Showing 20 changed files with 788 additions and 12 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ TODO
- Link
- List
- ListScroller
- Popup
- Portal
- Mark
- Menu
Expand Down
4 changes: 4 additions & 0 deletions __mocks__/lodash.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const _ = require.requireActual('lodash');

module.exports = _;
module.exports.debounce = jest.fn(_.debounce);
2 changes: 2 additions & 0 deletions setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,6 @@ const Adapter = require('enzyme-adapter-react-16');

Enzyme.configure({ adapter: new Adapter() });
jest.mock('react');
jest.mock('lodash');
jest.mock('lodash/debounce');
jest.mock('react-virtualized');
2 changes: 1 addition & 1 deletion src/ListScroller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface IListScrollerProps {
rowHeight: number | ((params: Index) => number);

// Responsible for rendering a row
rowRenderer: ({ key, index, value }: IListScrollerItemProps) => JSX.Element;
rowRenderer: ({ key, index, value, style }: IListScrollerItemProps) => JSX.Element;
noRowsRenderer?: () => JSX.Element;
onScroll?: () => void;
list: any[];
Expand Down
11 changes: 9 additions & 2 deletions src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface IMenuProps {
menuItems: IMenuItemProps[];
direction?: IFlexProps['direction'];
attributes?: IFlexProps;
onMouseEnter?: React.EventHandler<React.SyntheticEvent<HTMLDivElement>>;
onMouseLeave?: React.EventHandler<React.SyntheticEvent<HTMLDivElement>>;
renderTrigger?: () => any;
renderMenuItem?: RenderMenuItem;
renderMenu?: (
Expand All @@ -38,6 +40,8 @@ const MenuView = (props: IMenuProps & { className: string }) => {
attributes = null,
className,
direction = 'column',
onMouseEnter,
onMouseLeave,
menuItems = [],
renderTrigger,
renderMenuItem = (item: IMenuItemProps, index: number) => <MenuItem key={index} {...item} />,
Expand All @@ -49,6 +53,7 @@ const MenuView = (props: IMenuProps & { className: string }) => {
border="xs"
radius="md"
direction={direction}
position={renderTrigger ? 'absolute' : 'relative'}
{...attributes}
>
{menuItems.map(renderMenuItem)}
Expand All @@ -57,15 +62,17 @@ const MenuView = (props: IMenuProps & { className: string }) => {
} = props;

return (
<Flex className={className} direction={direction}>
<Flex className={className} direction={direction} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
{renderTrigger && <MenuTrigger>{renderTrigger()}</MenuTrigger>}
{renderMenu(props, menuItems, renderMenuItem)}
</Flex>
);
};

export const Menu = styled<IMenuProps, 'div'>(MenuView as any)`
&:hover ${Flex} {
position: relative;
&:hover > ${Flex} {
display: flex !important;
}
Expand Down
28 changes: 28 additions & 0 deletions src/Popup/PopupContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import * as React from 'react';

import { Portal } from '../Portal';
import { IPopupContentProps } from './types';

export const PopupContent = React.forwardRef<HTMLDivElement, IPopupContentProps>((props, ref) => {
const { onMouseEnter, onMouseLeave, repaint, style } = props;
const lastChildRef = React.useRef<HTMLElement>(null);

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

const count = React.Children.count(props.children);

return (
<Portal>
<div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} ref={ref} style={style}>
{React.Children.map(props.children, (child, i) => {
if (i !== count - 1) {
return child;
}

// the purpose of doing this is to get rid of that forced re-render
return React.cloneElement(child as React.ReactElement<any>, { ref: lastChildRef });
})}
</div>
</Portal>
);
});
126 changes: 126 additions & 0 deletions src/Popup/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import * as React from 'react';

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

export { IPopupProps, IPopupDefaultProps };

export const Popup = (props: IPopupProps) => {
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 | undefined>(undefined);
let isOverTrigger: boolean = false;
let isOverContent: boolean = false;
// Number could be set here, but unfortunately Node returns Timeout which is not exported
let willHide: any;

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

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

const showPopup = () => {
if (willHide !== undefined) {
clearTimeout(willHide);
willHide = undefined;
}

setVisibility(true);
};

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

willHide = setTimeout(() => {
isOverTrigger = false;
isOverContent = false;
setVisibility(false);
}, props.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,
}),
{
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
ref: triggerRef,
}
)}
{isVisible && (
<PopupContent
ref={contentRef}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
style={style}
repaint={repaint}
>
{renderContent({
...funcs,
isOver: isOverContent,
})}
</PopupContent>
)}
</>
);
};

Popup.defaultProps = {
padding: 15,
hideDelay: 200,
posX: 'left',
posY: 'top',
} as IPopupDefaultProps;
53 changes: 53 additions & 0 deletions src/Popup/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as React from 'react';

export type PopupTriggerRenderer = (
props: {
isVisible: boolean;
isOver: boolean;
showPopup: () => void;
hidePopup: () => void;
}
) => any;

export type PopupContentRenderer = (
props: {
isVisible: boolean;
isOver: boolean;
showPopup: () => void;
hidePopup: () => void;
}
) => any;

export interface IPopupPosition {
width?: number; // force a width
padding: number; // transparent space around the popup
offset?: {
top?: number;
bottom?: number;
left?: number;
right?: number;
};
posX: 'left' | 'center' | 'right';
posY: 'top' | 'center' | 'bottom';
}

export interface IPopupProps extends IPopupPosition {
hideDelay: number; // how long popup will show for after user mouses out
renderTrigger: PopupTriggerRenderer;
renderContent: PopupContentRenderer;
}

export interface IPopupDefaultProps {
padding: 15;
hideDelay: 200;
posX: 'left';
posY: 'top';
}

export interface IPopupContentProps {
children: React.ReactChildren;
onMouseEnter: (e: React.SyntheticEvent<HTMLElement>) => any;
onMouseLeave: (e: React.SyntheticEvent<HTMLElement>) => any;
repaint: () => any;
style?: React.CSSProperties;
}
Loading

0 comments on commit b3d9c9b

Please sign in to comment.