Skip to content

Commit 2644da6

Browse files
committed
Allow an arrow to be added to popover component
1 parent 3fd5193 commit 2644da6

File tree

4 files changed

+184
-27
lines changed

4 files changed

+184
-27
lines changed

src/components/feedback/Popover.tsx

Lines changed: 84 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import classnames from 'classnames';
22
import type { ComponentChildren, JSX, RefObject } from 'preact';
3-
import { useCallback, useEffect, useLayoutEffect } from 'preact/hooks';
3+
import {
4+
useCallback,
5+
useEffect,
6+
useLayoutEffect,
7+
useState,
8+
} from 'preact/hooks';
49

510
import { useClickAway } from '../../hooks/use-click-away';
611
import { useKeyPress } from '../../hooks/use-key-press';
712
import { useSyncedRef } from '../../hooks/use-synced-ref';
813
import { ListenerCollection } from '../../util/listener-collection';
914
import { downcastRef } from '../../util/typing';
15+
import { PointerDownIcon, PointerUpIcon } from '../icons';
1016

11-
/** Small space to apply between the anchor element and the popover */
12-
const POPOVER_ANCHOR_EL_GAP = '.15rem';
17+
/** Small space in px, to apply between the anchor element and the popover */
18+
const POPOVER_ANCHOR_EL_GAP = 3;
1319

1420
/**
1521
* Space in pixels to apply between the popover and the viewport sides to
@@ -35,6 +41,9 @@ type PopoverPositioningOptions = {
3541
*/
3642
alignToRight: boolean;
3743

44+
/** Whether an arrow pointing to the anchor should be added. */
45+
arrow: boolean;
46+
3847
/** Native popover API is used to toggle the popover */
3948
asNativePopover: boolean;
4049
};
@@ -48,8 +57,16 @@ type PopoverPositioningOptions = {
4857
function usePopoverPositioning(
4958
popoverRef: RefObject<HTMLElement | undefined>,
5059
anchorRef: RefObject<HTMLElement | undefined>,
51-
{ open, asNativePopover, alignToRight, placement }: PopoverPositioningOptions,
60+
{
61+
open,
62+
asNativePopover,
63+
alignToRight,
64+
placement,
65+
arrow,
66+
}: PopoverPositioningOptions,
5267
) {
68+
const [resolvedPlacement, setResolvedPlacement] = useState(placement);
69+
5370
const adjustPopoverPositioning = useCallback(() => {
5471
const popoverEl = popoverRef.current!;
5572
const anchorEl = anchorRef.current!;
@@ -89,18 +106,23 @@ function usePopoverPositioning(
89106
anchorElDistanceToBottom < popoverHeight &&
90107
anchorElDistanceToTop > anchorElDistanceToBottom);
91108

109+
// Update the actual placement, which may not match provided one
110+
setResolvedPlacement(shouldBeAbove ? 'above' : 'below');
111+
112+
const anchorGap = arrow ? POPOVER_ANCHOR_EL_GAP + 8 : POPOVER_ANCHOR_EL_GAP;
113+
92114
if (!asNativePopover) {
93115
// Set styles for non-popover mode
94116
if (shouldBeAbove) {
95117
return setPopoverCSSProps({
96118
bottom: '100%',
97-
marginBottom: POPOVER_ANCHOR_EL_GAP,
119+
marginBottom: `${anchorGap}px`,
98120
});
99121
}
100122

101123
return setPopoverCSSProps({
102124
top: '100%',
103-
marginTop: POPOVER_ANCHOR_EL_GAP,
125+
marginTop: `${anchorGap}px`,
104126
});
105127
}
106128

@@ -134,11 +156,11 @@ function usePopoverPositioning(
134156
return setPopoverCSSProps({
135157
minWidth: `${anchorElWidth}px`,
136158
top: shouldBeAbove
137-
? `calc(${absBodyTop + anchorElDistanceToTop - popoverHeight}px - ${POPOVER_ANCHOR_EL_GAP})`
138-
: `calc(${absBodyTop + anchorElDistanceToTop + anchorElHeight}px + ${POPOVER_ANCHOR_EL_GAP})`,
159+
? `${absBodyTop + anchorElDistanceToTop - popoverHeight - anchorGap}px`
160+
: `${absBodyTop + anchorElDistanceToTop + anchorElHeight + anchorGap}px`,
139161
left: `${Math.max(POPOVER_VIEWPORT_HORIZONTAL_GAP, left)}px`,
140162
});
141-
}, [asNativePopover, anchorRef, popoverRef, alignToRight, placement]);
163+
}, [popoverRef, anchorRef, placement, arrow, asNativePopover, alignToRight]);
142164

143165
useLayoutEffect(() => {
144166
if (!open) {
@@ -179,6 +201,8 @@ function usePopoverPositioning(
179201
observer.disconnect();
180202
};
181203
}, [adjustPopoverPositioning, asNativePopover, open, popoverRef]);
204+
205+
return resolvedPlacement;
182206
}
183207

184208
/**
@@ -272,6 +296,13 @@ export type PopoverProps = {
272296
*/
273297
placement?: 'above' | 'below';
274298

299+
/**
300+
* Determines if a small arrow pointing to the anchor element should be
301+
* displayed.
302+
* Defaults to false.
303+
*/
304+
arrow?: boolean;
305+
275306
/**
276307
* Determines if focus should be restored when the popover is closed.
277308
* Defaults to true.
@@ -353,6 +384,7 @@ export default function Popover({
353384
onClose,
354385
align = 'left',
355386
placement = 'below',
387+
arrow = false,
356388
classes,
357389
variant = 'panel',
358390
onScroll,
@@ -362,12 +394,17 @@ export default function Popover({
362394
}: PopoverProps) {
363395
const popoverRef = useSyncedRef<HTMLElement>(elementRef);
364396

365-
usePopoverPositioning(popoverRef, anchorElementRef, {
366-
open,
367-
placement,
368-
alignToRight: align === 'right',
369-
asNativePopover,
370-
});
397+
const resolvedPlacement = usePopoverPositioning(
398+
popoverRef,
399+
anchorElementRef,
400+
{
401+
open,
402+
placement,
403+
arrow,
404+
alignToRight: align === 'right',
405+
asNativePopover,
406+
},
407+
);
371408
useOnClose(popoverRef, anchorElementRef, onClose, open, asNativePopover);
372409
useRestoreFocusOnClose({
373410
open,
@@ -378,16 +415,12 @@ export default function Popover({
378415
<div
379416
className={classnames(
380417
'absolute z-5',
381-
variant === 'panel' && [
382-
'max-h-80 overflow-y-auto overflow-x-hidden',
383-
'rounded border bg-white shadow hover:shadow-md focus-within:shadow-md',
384-
],
385418
asNativePopover && [
386419
// We don't want the popover to ever render outside the viewport,
387420
// and we give it a 16px gap
388421
'max-w-[calc(100%-16px)]',
389422
// Overwrite [popover] default styles
390-
'p-0 m-0',
423+
'p-0 m-0 overflow-visible',
391424
],
392425
!asNativePopover && {
393426
// Hiding instead of unmounting so that popover size can be computed
@@ -396,15 +429,44 @@ export default function Popover({
396429
'right-0': align === 'right',
397430
'min-w-full': true,
398431
},
399-
classes,
400432
)}
401433
ref={downcastRef(popoverRef)}
402434
popover={asNativePopover && 'auto'}
403435
onScroll={onScroll}
404436
data-testid="popover"
405437
data-component="Popover"
406438
>
407-
{open && children}
439+
{open && arrow && (
440+
<div
441+
className={classnames('absolute z-10', 'fill-white text-grey-3', {
442+
'top-[calc(100%-1px)]': resolvedPlacement === 'above',
443+
'bottom-[calc(100%-1px)]': resolvedPlacement === 'below',
444+
'left-2': align === 'left',
445+
'right-2': align === 'right',
446+
})}
447+
data-testid="arrow"
448+
>
449+
{resolvedPlacement === 'below' ? (
450+
<PointerUpIcon />
451+
) : (
452+
<PointerDownIcon />
453+
)}
454+
</div>
455+
)}
456+
{open && (
457+
<div
458+
className={classnames(
459+
variant === 'panel' && [
460+
'max-h-80 overflow-y-auto overflow-x-hidden',
461+
'rounded border bg-white shadow hover:shadow-md focus-within:shadow-md',
462+
],
463+
classes,
464+
)}
465+
data-testid="popover-content"
466+
>
467+
{children}
468+
</div>
469+
)}
408470
</div>
409471
);
410472
}

src/components/feedback/test/Popover-test.js

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,17 +75,23 @@ describe('Popover', () => {
7575
return popoverTop < buttonTop;
7676
};
7777

78-
const getDistanceBetweenButtonAndPopover = wrapper => {
78+
const getDistanceBetweenButtonAndElement = (wrapper, element) => {
7979
const appearedAbove = popoverAppearedAbove(wrapper);
80-
const { top: popoverTop, bottom: popoverBottom } = getPopover(wrapper)
81-
.getDOMNode()
82-
.getBoundingClientRect();
80+
const { top: elementTop, bottom: elementBottom } =
81+
element.getBoundingClientRect();
8382
const { top: buttonTop, bottom: buttonBottom } = getToggleButton(wrapper)
8483
.getDOMNode()
8584
.getBoundingClientRect();
8685

8786
return Math.abs(
88-
appearedAbove ? popoverBottom - buttonTop : buttonBottom - popoverTop,
87+
appearedAbove ? elementBottom - buttonTop : buttonBottom - elementTop,
88+
);
89+
};
90+
91+
const getDistanceBetweenButtonAndPopover = wrapper => {
92+
return getDistanceBetweenButtonAndElement(
93+
wrapper,
94+
getPopover(wrapper).getDOMNode(),
8995
);
9096
};
9197

@@ -457,6 +463,46 @@ describe('Popover', () => {
457463
});
458464
});
459465

466+
describe('popover with arrow', () => {
467+
[
468+
{
469+
arrow: true,
470+
placement: 'above',
471+
expectedPointer: 'PointerDownIcon',
472+
expectedOffset: 14,
473+
},
474+
{
475+
arrow: true,
476+
placement: 'below',
477+
expectedPointer: 'PointerUpIcon',
478+
expectedOffset: 14,
479+
},
480+
{ arrow: false, placement: 'above', expectedOffset: 6 },
481+
{ arrow: false, placement: 'below', expectedOffset: 6 },
482+
].forEach(({ arrow, placement, expectedPointer, expectedOffset }) => {
483+
it('increases the offset between the anchor and the popover when arrow is true', () => {
484+
const wrapper = createComponent(
485+
{ placement, arrow },
486+
// Add some space so that the popover can render above
487+
{ paddingTop: 100 },
488+
);
489+
togglePopover(wrapper);
490+
491+
const offset = getDistanceBetweenButtonAndElement(
492+
wrapper,
493+
wrapper.find('[data-testid="popover-content"]').getDOMNode(),
494+
);
495+
assert.equal(offset, expectedOffset);
496+
497+
if (arrow) {
498+
assert.isTrue(wrapper.exists(expectedPointer));
499+
} else {
500+
assert.isFalse(wrapper.exists('[data-testid="arrow"]'));
501+
}
502+
});
503+
});
504+
});
505+
460506
it('sets `elementRef`', () => {
461507
const elementRef = { current: null };
462508
const wrapper = createComponent({ elementRef });

src/pattern-library/components/patterns/feedback/PopoverPage.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,25 @@ export default function PopoverPage() {
9292
</Library.InfoItem>
9393
</Library.Info>
9494
</Library.SectionL3>
95+
<Library.SectionL3 title="arrow">
96+
<Library.Info>
97+
<Library.InfoItem label="description">
98+
Determines if a small arrow pointing to the anchor element
99+
should be displayed.
100+
</Library.InfoItem>
101+
<Library.InfoItem label="type">
102+
<code>{'true | false'}</code>
103+
</Library.InfoItem>
104+
<Library.InfoItem label="default">
105+
<code>{'false'}</code>
106+
</Library.InfoItem>
107+
</Library.Info>
108+
<Library.Demo
109+
title="Popover with arrow"
110+
exampleFile="popover-with-arrow"
111+
withSource
112+
/>
113+
</Library.SectionL3>
95114
<Library.SectionL3 title="asNativePopover">
96115
<Library.Info>
97116
<Library.InfoItem label="description">
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useRef, useState } from 'preact/hooks';
2+
3+
import { Popover } from '../../components/feedback';
4+
import { Button } from '../../components/input';
5+
6+
export default function App() {
7+
const [open, setOpen] = useState(false);
8+
const buttonRef = useRef<HTMLButtonElement | null>(null);
9+
10+
return (
11+
<div className="relative flex justify-center">
12+
<Button
13+
variant="primary"
14+
elementRef={buttonRef}
15+
onClick={() => setOpen(prev => !prev)}
16+
>
17+
{open ? 'Close' : 'Open'} Popover
18+
</Button>
19+
<Popover
20+
open={open}
21+
onClose={() => setOpen(false)}
22+
anchorElementRef={buttonRef}
23+
classes="p-2"
24+
arrow
25+
>
26+
The content of the popover goes here
27+
</Popover>
28+
</div>
29+
);
30+
}

0 commit comments

Comments
 (0)