Skip to content

Commit 0c49ab3

Browse files
authored
feat: Add Popover component to wave (#230)
* feat(popover): add popover component to wave * feat(popover): add props table to docz * refactor: move util and helper methods to common utils * feat(popover): simplify the implementation of popover * feat(popover): remove unneeded comments * feat(popover): add aria attributes, describe placements in props table * feat(popover): change variable name for popover content state reference * Update src/components/Popover/docs/Popover.mdx * feat(popover): fix some of the docs texts * feat(popover): update useEffect with openByDefault * feat(popover): remove useStateWithTimeout hook
1 parent 6c44df0 commit 0c49ab3

File tree

8 files changed

+605
-120
lines changed

8 files changed

+605
-120
lines changed

package-lock.json

Lines changed: 111 additions & 120 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,12 +113,14 @@
113113
},
114114
"dependencies": {
115115
"@datepicker-react/hooks": "^2.3.1",
116+
"@popperjs/core": "^2.11.5",
116117
"@styled-system/theme-get": "^5.1.2",
117118
"@testing-library/react-hooks": "^8.0.0",
118119
"@types/react-select": "^3.0.13",
119120
"@types/styled-system": "^5.1.9",
120121
"date-fns": "^2.11.1",
121122
"nanoid": "^3.1.23",
123+
"react-popper": "^2.3.0",
122124
"react-tether": "^2.0.7",
123125
"react-transition-group": "^4.3.0",
124126
"react-windowed-select": "^2.0.2",

src/components/Popover/Popover.tsx

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import * as React from 'react';
2+
import styled from 'styled-components';
3+
import { Placement } from '@popperjs/core/lib/enums';
4+
import { usePopper } from 'react-popper';
5+
6+
import { theme } from '../../essentials/theme';
7+
import { get } from '../../utils/themeGet';
8+
import { Colors, Spaces } from '../../essentials';
9+
import { ChevronDownIcon, ChevronUpIcon } from '../../icons/index';
10+
import { useClickOutside } from '../../utils/hooks/useClickOutside';
11+
12+
import { Text } from '../Text/Text';
13+
14+
import { PopoverContent } from './PopoverContent';
15+
16+
interface PopoverRefObjectProps {
17+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
18+
ref: any;
19+
}
20+
21+
const PopoverTrigger = styled.div.attrs({ theme })<PopoverRefObjectProps>`
22+
display: inline-block;
23+
width: fit-content;
24+
border-radius: ${get('radii.2')};
25+
`;
26+
27+
const DefaultPopoverWrapper = styled.div.attrs({ theme })`
28+
position: relative;
29+
display: flex;
30+
align-items: center;
31+
justify-content: center;
32+
border: 1px solid ${get('semanticColors.button.secondary.borderHover')};
33+
padding: 0.8125rem ${Spaces[2]};
34+
border-radius: ${get('radii.2')};
35+
36+
&:hover {
37+
cursor: pointer;
38+
background-color: ${get('semanticColors.background.secondary')} !important;
39+
}
40+
`;
41+
42+
const PopoverContentContainer = styled.div<PopoverRefObjectProps>`
43+
display: inline-block;
44+
`;
45+
46+
const PopoverContentWrapper = styled.div`
47+
display: inline-block;
48+
box-sizing: border-box;
49+
width: auto;
50+
height: auto;
51+
z-index: 1000;
52+
box-shadow: ${get('shadows.small')};
53+
max-height: none;
54+
55+
&:focus {
56+
outline: 0;
57+
}
58+
`;
59+
60+
const KEY_CODE_MAP = {
61+
ENTER: 13,
62+
SPACE: 32,
63+
ESC: 27
64+
};
65+
66+
interface PopoverProps {
67+
/**
68+
* Popover Trigger (Only use Text, Link, Button or Icon component from @wave as a trigger)
69+
*/
70+
children: React.ReactNode;
71+
/**
72+
* Popover content (can be any valid React Element or component containing React Elements)
73+
*/
74+
content: React.ReactNode;
75+
/**
76+
* Optional: Specify the Popover content placement (it changes automatically if the Popover content cannot fit inside the viewport with the selected placement)
77+
*/
78+
placement?: Placement;
79+
/**
80+
* Optional: Specify the Popover content offset (margin between Popover trigger and content)
81+
*/
82+
offset?: number;
83+
/**
84+
* Optional: Render popover content open by default
85+
*/
86+
isOpen?: boolean;
87+
/**
88+
* Optional: Define a callback for when Popover content is opened
89+
*/
90+
onOpen?: () => void;
91+
/**
92+
* Optional: Define a callback for when Popover content is closed
93+
*/
94+
onClose?: () => void;
95+
}
96+
97+
export const Popover: React.FC<PopoverProps> = ({
98+
children,
99+
content = '',
100+
placement = 'bottom-start',
101+
offset = 5,
102+
isOpen = false,
103+
onOpen,
104+
onClose
105+
}: PopoverProps) => {
106+
const [triggerReference, setTriggerReference] = React.useState(undefined);
107+
const [contentReference, setContentReference] = React.useState(undefined);
108+
const popoverTriggerRef = React.useRef<HTMLDivElement>(null);
109+
const popoverContentRef = React.useRef<HTMLDivElement>(null);
110+
111+
const [openByDefault, setOpenByDefault] = React.useState(isOpen);
112+
113+
const [render, setRender] = React.useState(openByDefault);
114+
115+
const { styles, attributes } = usePopper(triggerReference, contentReference, {
116+
placement,
117+
strategy: 'fixed',
118+
modifiers: [
119+
{
120+
name: 'offset',
121+
enabled: !!offset,
122+
options: {
123+
offset: [0, offset]
124+
}
125+
},
126+
{
127+
name: 'flip',
128+
enabled: true
129+
}
130+
]
131+
});
132+
133+
const resolveCallback = React.useCallback(
134+
state => {
135+
if (onClose && !state) onClose();
136+
if (onOpen && state) onOpen();
137+
},
138+
[onClose, onOpen]
139+
);
140+
141+
const hidePopover: () => void = React.useCallback(() => {
142+
if (openByDefault) {
143+
setOpenByDefault(false);
144+
} else {
145+
setRender(false);
146+
}
147+
resolveCallback(false);
148+
}, [openByDefault, resolveCallback]);
149+
150+
const handleClose = React.useCallback(() => {
151+
if (render) {
152+
hidePopover();
153+
}
154+
}, [render, hidePopover]);
155+
156+
const handleClick: () => void = React.useCallback(() => {
157+
if (render) {
158+
hidePopover();
159+
} else {
160+
setRender(true);
161+
resolveCallback(true);
162+
}
163+
}, [resolveCallback, setRender, render, hidePopover]);
164+
165+
const handleOut = React.useCallback(
166+
ev => {
167+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
168+
if (popoverTriggerRef && popoverTriggerRef.current && !popoverTriggerRef.current.contains(ev.target)) {
169+
if (!openByDefault) {
170+
handleClose();
171+
} else {
172+
setOpenByDefault(false);
173+
resolveCallback(false);
174+
}
175+
}
176+
},
177+
[openByDefault, popoverTriggerRef, handleClose, resolveCallback]
178+
);
179+
180+
const handleKeyControl = (ev: React.KeyboardEvent<HTMLElement>) => {
181+
// eslint-disable-next-line default-case
182+
switch (ev.keyCode) {
183+
case KEY_CODE_MAP.ESC:
184+
handleClose();
185+
break;
186+
case KEY_CODE_MAP.ENTER:
187+
handleClick();
188+
break;
189+
case KEY_CODE_MAP.SPACE:
190+
handleClick();
191+
}
192+
};
193+
194+
React.useEffect(() => {
195+
setRender(openByDefault);
196+
}, [openByDefault, setRender]);
197+
198+
useClickOutside(popoverContentRef, ev => handleOut(ev));
199+
200+
return (
201+
<>
202+
<PopoverTrigger
203+
ref={setTriggerReference}
204+
onClick={handleClick}
205+
tabIndex={0}
206+
aria-describedby="popover-content"
207+
aria-haspopup
208+
onKeyDown={ev => handleKeyControl(ev)}
209+
>
210+
{typeof children === 'string' ? (
211+
<DefaultPopoverWrapper
212+
ref={popoverTriggerRef}
213+
style={{ background: render ? Colors.AUTHENTIC_BLUE_50 : 'none' }}
214+
>
215+
<Text fontWeight="semibold">{children}</Text>
216+
{!render ? (
217+
<ChevronDownIcon size={20} style={{ marginLeft: Spaces[1] }} />
218+
) : (
219+
<ChevronUpIcon size={20} style={{ marginLeft: Spaces[1] }} />
220+
)}
221+
</DefaultPopoverWrapper>
222+
) : (
223+
<div ref={popoverTriggerRef}>{children}</div>
224+
)}
225+
</PopoverTrigger>
226+
227+
{render && (
228+
<PopoverContentContainer
229+
id="popover-content"
230+
ref={setContentReference}
231+
// zIndex temporary until we have Portal component
232+
style={{ ...styles.popper, zIndex: 9999 }}
233+
{...attributes.popper}
234+
>
235+
<PopoverContentWrapper ref={popoverContentRef}>
236+
<PopoverContent>{content}</PopoverContent>
237+
</PopoverContentWrapper>
238+
</PopoverContentContainer>
239+
)}
240+
</>
241+
);
242+
};
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import React from 'react';
2+
import styled from 'styled-components';
3+
4+
import { Spaces } from '../../essentials';
5+
import { Card } from '../Card/Card';
6+
7+
interface PopoverContentProps {
8+
/**
9+
* Popover content (can be any valid React Element or component)
10+
*/
11+
children: React.ReactNode;
12+
}
13+
14+
const PopoverContentContainer = styled(Card)`
15+
display: block;
16+
padding: ${Spaces[2]};
17+
`;
18+
19+
export const PopoverContent = ({ children }: PopoverContentProps): React.ReactElement => (
20+
<>
21+
<PopoverContentContainer level={200}>{children}</PopoverContentContainer>
22+
</>
23+
);

0 commit comments

Comments
 (0)