Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: popover #234

Merged
merged 13 commits into from
Jun 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 111 additions & 120 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,13 @@
},
"dependencies": {
"@datepicker-react/hooks": "^2.3.1",
"@popperjs/core": "^2.11.5",
"@styled-system/theme-get": "^5.1.2",
"@types/react-select": "^3.0.13",
"@types/styled-system": "^5.1.9",
"date-fns": "^2.11.1",
"nanoid": "^3.1.23",
"react-popper": "^2.3.0",
"react-tether": "^2.0.7",
"react-transition-group": "^4.3.0",
"react-windowed-select": "^2.0.2",
Expand Down
242 changes: 242 additions & 0 deletions src/components/Popover/Popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import * as React from 'react';
import styled from 'styled-components';
import { Placement } from '@popperjs/core/lib/enums';
import { usePopper } from 'react-popper';

import { theme } from '../../essentials/theme';
import { get } from '../../utils/themeGet';
import { Colors, Spaces } from '../../essentials';
import { ChevronDownIcon, ChevronUpIcon } from '../../icons/index';
import { useClickOutside } from '../../utils/hooks/useClickOutside';

import { Text } from '../Text/Text';

import { PopoverContent } from './PopoverContent';

interface PopoverRefObjectProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ref: any;
}

const PopoverTrigger = styled.div.attrs({ theme })<PopoverRefObjectProps>`
display: inline-block;
width: fit-content;
border-radius: ${get('radii.2')};
`;

const DefaultPopoverWrapper = styled.div.attrs({ theme })`
position: relative;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid ${get('semanticColors.button.secondary.borderHover')};
padding: 0.8125rem ${Spaces[2]};
border-radius: ${get('radii.2')};

&:hover {
cursor: pointer;
background-color: ${get('semanticColors.background.secondary')} !important;
}
`;

const PopoverContentContainer = styled.div<PopoverRefObjectProps>`
display: inline-block;
`;

const PopoverContentWrapper = styled.div`
display: inline-block;
box-sizing: border-box;
width: auto;
height: auto;
z-index: 1000;
box-shadow: ${get('shadows.small')};
max-height: none;

&:focus {
outline: 0;
}
`;

const KEY_CODE_MAP = {
ENTER: 13,
SPACE: 32,
ESC: 27
};

interface PopoverProps {
/**
* Popover Trigger (Only use Text, Link, Button or Icon component from @wave as a trigger)
*/
children: React.ReactNode;
/**
* Popover content (can be any valid React Element or component containing React Elements)
*/
content: React.ReactNode;
/**
* Optional: Specify the Popover content placement (it changes automatically if the Popover content cannot fit inside the viewport with the selected placement)
*/
placement?: Placement;
/**
* Optional: Specify the Popover content offset (margin between Popover trigger and content)
*/
offset?: number;
/**
* Optional: Render popover content open by default
*/
isOpen?: boolean;
/**
* Optional: Define a callback for when Popover content is opened
*/
onOpen?: () => void;
/**
* Optional: Define a callback for when Popover content is closed
*/
onClose?: () => void;
}

export const Popover: React.FC<PopoverProps> = ({
children,
content = '',
placement = 'bottom-start',
offset = 5,
isOpen = false,
onOpen,
onClose
}: PopoverProps) => {
const [triggerReference, setTriggerReference] = React.useState(undefined);
const [contentReference, setContentReference] = React.useState(undefined);
const popoverTriggerRef = React.useRef<HTMLDivElement>(null);
const popoverContentRef = React.useRef<HTMLDivElement>(null);

const [openByDefault, setOpenByDefault] = React.useState(isOpen);

const [render, setRender] = React.useState(openByDefault);

const { styles, attributes } = usePopper(triggerReference, contentReference, {
placement,
strategy: 'fixed',
modifiers: [
{
name: 'offset',
enabled: !!offset,
options: {
offset: [0, offset]
}
},
{
name: 'flip',
enabled: true
}
]
});

const resolveCallback = React.useCallback(
state => {
if (onClose && !state) onClose();
if (onOpen && state) onOpen();
},
[onClose, onOpen]
);

const hidePopover: () => void = React.useCallback(() => {
if (openByDefault) {
setOpenByDefault(false);
} else {
setRender(false);
}
resolveCallback(false);
}, [openByDefault, resolveCallback]);

const handleClose = React.useCallback(() => {
if (render) {
hidePopover();
}
}, [render, hidePopover]);

const handleClick: () => void = React.useCallback(() => {
if (render) {
hidePopover();
} else {
setRender(true);
resolveCallback(true);
}
}, [resolveCallback, setRender, render, hidePopover]);

const handleOut = React.useCallback(
ev => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (popoverTriggerRef && popoverTriggerRef.current && !popoverTriggerRef.current.contains(ev.target)) {
if (!openByDefault) {
handleClose();
} else {
setOpenByDefault(false);
resolveCallback(false);
}
}
},
[openByDefault, popoverTriggerRef, handleClose, resolveCallback]
);

const handleKeyControl = (ev: React.KeyboardEvent<HTMLElement>) => {
// eslint-disable-next-line default-case
switch (ev.keyCode) {
case KEY_CODE_MAP.ESC:
handleClose();
break;
case KEY_CODE_MAP.ENTER:
handleClick();
break;
case KEY_CODE_MAP.SPACE:
handleClick();
}
};

React.useEffect(() => {
setRender(openByDefault);
}, [openByDefault, setRender]);

useClickOutside(popoverContentRef, ev => handleOut(ev));

return (
<>
<PopoverTrigger
ref={setTriggerReference}
onClick={handleClick}
tabIndex={0}
aria-describedby="popover-content"
aria-haspopup
onKeyDown={ev => handleKeyControl(ev)}
>
{typeof children === 'string' ? (
<DefaultPopoverWrapper
ref={popoverTriggerRef}
style={{ background: render ? Colors.AUTHENTIC_BLUE_50 : 'none' }}
>
<Text fontWeight="semibold">{children}</Text>
{!render ? (
<ChevronDownIcon size={20} style={{ marginLeft: Spaces[1] }} />
) : (
<ChevronUpIcon size={20} style={{ marginLeft: Spaces[1] }} />
)}
</DefaultPopoverWrapper>
) : (
<div ref={popoverTriggerRef}>{children}</div>
)}
</PopoverTrigger>

{render && (
<PopoverContentContainer
id="popover-content"
ref={setContentReference}
// zIndex temporary until we have Portal component
style={{ ...styles.popper, zIndex: 9999 }}
{...attributes.popper}
>
<PopoverContentWrapper ref={popoverContentRef}>
<PopoverContent>{content}</PopoverContent>
</PopoverContentWrapper>
</PopoverContentContainer>
)}
</>
);
};
23 changes: 23 additions & 0 deletions src/components/Popover/PopoverContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import styled from 'styled-components';

import { Spaces } from '../../essentials';
import { Card } from '../Card/Card';

interface PopoverContentProps {
/**
* Popover content (can be any valid React Element or component)
*/
children: React.ReactNode;
}

const PopoverContentContainer = styled(Card)`
display: block;
padding: ${Spaces[2]};
`;

export const PopoverContent = ({ children }: PopoverContentProps): React.ReactElement => (
<>
<PopoverContentContainer level={200}>{children}</PopoverContentContainer>
</>
);
Loading