11import classnames from 'classnames' ;
22import 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
510import { useClickAway } from '../../hooks/use-click-away' ;
611import { useKeyPress } from '../../hooks/use-key-press' ;
712import { useSyncedRef } from '../../hooks/use-synced-ref' ;
813import { ListenerCollection } from '../../util/listener-collection' ;
914import { 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 = {
4857function 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}
0 commit comments