1
1
import classnames from 'classnames' ;
2
2
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' ;
4
9
5
10
import { useClickAway } from '../../hooks/use-click-away' ;
6
11
import { useKeyPress } from '../../hooks/use-key-press' ;
7
12
import { useSyncedRef } from '../../hooks/use-synced-ref' ;
8
13
import { ListenerCollection } from '../../util/listener-collection' ;
9
14
import { downcastRef } from '../../util/typing' ;
15
+ import { PointerDownIcon , PointerUpIcon } from '../icons' ;
10
16
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 ;
13
19
14
20
/**
15
21
* Space in pixels to apply between the popover and the viewport sides to
@@ -35,6 +41,9 @@ type PopoverPositioningOptions = {
35
41
*/
36
42
alignToRight : boolean ;
37
43
44
+ /** Whether an arrow pointing to the anchor should be added. */
45
+ arrow : boolean ;
46
+
38
47
/** Native popover API is used to toggle the popover */
39
48
asNativePopover : boolean ;
40
49
} ;
@@ -48,8 +57,16 @@ type PopoverPositioningOptions = {
48
57
function usePopoverPositioning (
49
58
popoverRef : RefObject < HTMLElement | undefined > ,
50
59
anchorRef : RefObject < HTMLElement | undefined > ,
51
- { open, asNativePopover, alignToRight, placement } : PopoverPositioningOptions ,
60
+ {
61
+ open,
62
+ asNativePopover,
63
+ alignToRight,
64
+ placement,
65
+ arrow,
66
+ } : PopoverPositioningOptions ,
52
67
) {
68
+ const [ resolvedPlacement , setResolvedPlacement ] = useState ( placement ) ;
69
+
53
70
const adjustPopoverPositioning = useCallback ( ( ) => {
54
71
const popoverEl = popoverRef . current ! ;
55
72
const anchorEl = anchorRef . current ! ;
@@ -89,18 +106,23 @@ function usePopoverPositioning(
89
106
anchorElDistanceToBottom < popoverHeight &&
90
107
anchorElDistanceToTop > anchorElDistanceToBottom ) ;
91
108
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
+
92
114
if ( ! asNativePopover ) {
93
115
// Set styles for non-popover mode
94
116
if ( shouldBeAbove ) {
95
117
return setPopoverCSSProps ( {
96
118
bottom : '100%' ,
97
- marginBottom : POPOVER_ANCHOR_EL_GAP ,
119
+ marginBottom : ` ${ anchorGap } px` ,
98
120
} ) ;
99
121
}
100
122
101
123
return setPopoverCSSProps ( {
102
124
top : '100%' ,
103
- marginTop : POPOVER_ANCHOR_EL_GAP ,
125
+ marginTop : ` ${ anchorGap } px` ,
104
126
} ) ;
105
127
}
106
128
@@ -134,11 +156,11 @@ function usePopoverPositioning(
134
156
return setPopoverCSSProps ( {
135
157
minWidth : `${ anchorElWidth } px` ,
136
158
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 ` ,
139
161
left : `${ Math . max ( POPOVER_VIEWPORT_HORIZONTAL_GAP , left ) } px` ,
140
162
} ) ;
141
- } , [ asNativePopover , anchorRef , popoverRef , alignToRight , placement ] ) ;
163
+ } , [ popoverRef , anchorRef , placement , arrow , asNativePopover , alignToRight ] ) ;
142
164
143
165
useLayoutEffect ( ( ) => {
144
166
if ( ! open ) {
@@ -179,6 +201,8 @@ function usePopoverPositioning(
179
201
observer . disconnect ( ) ;
180
202
} ;
181
203
} , [ adjustPopoverPositioning , asNativePopover , open , popoverRef ] ) ;
204
+
205
+ return resolvedPlacement ;
182
206
}
183
207
184
208
/**
@@ -272,6 +296,13 @@ export type PopoverProps = {
272
296
*/
273
297
placement ?: 'above' | 'below' ;
274
298
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
+
275
306
/**
276
307
* Determines if focus should be restored when the popover is closed.
277
308
* Defaults to true.
@@ -353,6 +384,7 @@ export default function Popover({
353
384
onClose,
354
385
align = 'left' ,
355
386
placement = 'below' ,
387
+ arrow = false ,
356
388
classes,
357
389
variant = 'panel' ,
358
390
onScroll,
@@ -362,12 +394,17 @@ export default function Popover({
362
394
} : PopoverProps ) {
363
395
const popoverRef = useSyncedRef < HTMLElement > ( elementRef ) ;
364
396
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
+ ) ;
371
408
useOnClose ( popoverRef , anchorElementRef , onClose , open , asNativePopover ) ;
372
409
useRestoreFocusOnClose ( {
373
410
open,
@@ -378,16 +415,12 @@ export default function Popover({
378
415
< div
379
416
className = { classnames (
380
417
'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
- ] ,
385
418
asNativePopover && [
386
419
// We don't want the popover to ever render outside the viewport,
387
420
// and we give it a 16px gap
388
421
'max-w-[calc(100%-16px)]' ,
389
422
// Overwrite [popover] default styles
390
- 'p-0 m-0' ,
423
+ 'p-0 m-0 overflow-visible ' ,
391
424
] ,
392
425
! asNativePopover && {
393
426
// Hiding instead of unmounting so that popover size can be computed
@@ -396,15 +429,44 @@ export default function Popover({
396
429
'right-0' : align === 'right' ,
397
430
'min-w-full' : true ,
398
431
} ,
399
- classes ,
400
432
) }
401
433
ref = { downcastRef ( popoverRef ) }
402
434
popover = { asNativePopover && 'auto' }
403
435
onScroll = { onScroll }
404
436
data-testid = "popover"
405
437
data-component = "Popover"
406
438
>
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
+ ) }
408
470
</ div >
409
471
) ;
410
472
}
0 commit comments