Skip to content
This repository was archived by the owner on Jun 28, 2026. It is now read-only.

Commit e55be0a

Browse files
committed
feat: add Popover component
1 parent 4e28fa5 commit e55be0a

30 files changed

Lines changed: 1090 additions & 0 deletions
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './usePopover';
2+
export * from './usePopover.props';
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { usePopoverProps } from '@primereact/types/shared/popover';
2+
3+
export const defaultProps: usePopoverProps = {
4+
dismissable: true,
5+
appendTo: 'body',
6+
baseZIndex: 0,
7+
autoZIndex: true,
8+
breakpoints: {},
9+
closeOnEscape: true,
10+
defaultOpen: undefined,
11+
open: undefined,
12+
onOpenChange: undefined,
13+
triggerRef: undefined,
14+
containerRef: undefined
15+
};

packages/headless/src/popover/usePopover.test.ts

Whitespace-only changes.
Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
import { withHeadless } from '@primereact/core/headless';
2+
import { ConnectedOverlayScrollHandler } from '@primereact/core/utils';
3+
import { useMountEffect } from '@primereact/hooks/use-mount-effect';
4+
import { useUnmountEffect } from '@primereact/hooks/use-unmount-effect';
5+
import { $dt } from '@primeuix/styled';
6+
import { absolutePosition, addClass, addStyle, focus, getOffset, isClient, isTouchDevice, setAttribute } from '@primeuix/utils/dom';
7+
import { ZIndex } from '@primeuix/utils/zindex';
8+
import { OverlayEventBus } from 'primereact/overlayeventbus';
9+
import * as React from 'react';
10+
import { defaultProps } from './usePopover.props';
11+
12+
export const usePopover = withHeadless({
13+
name: 'usePopover',
14+
defaultProps,
15+
setup: ({ props }) => {
16+
const { dismissable, baseZIndex = 0, autoZIndex, closeOnEscape, defaultOpen, open, onOpenChange, breakpoints } = props;
17+
const [visibleState, setVisibleState] = React.useState(false);
18+
const selfClick = React.useRef(false);
19+
const overlayEventListeners = React.useRef<((e: unknown) => void) | null>(null);
20+
const scrollHandler = React.useRef<ConnectedOverlayScrollHandler | null>(null);
21+
const resizeListener = React.useRef<() => void | null>(null);
22+
const outsideClickListener = React.useRef<((e: unknown) => void) | null>(null);
23+
const styleElement = React.useRef<HTMLStyleElement | null>(null);
24+
const documentKeydownListener = React.useRef<((e: unknown) => void) | null>(null);
25+
const triggerRef = React.useRef<HTMLElement | null>(null);
26+
const containerRef = React.useRef<HTMLElement | null>(null);
27+
28+
const state = {
29+
visible: visibleState
30+
};
31+
32+
const getTrigger = React.useCallback(() => {
33+
if (triggerRef?.current && triggerRef?.current instanceof HTMLElement) {
34+
return triggerRef?.current;
35+
}
36+
37+
// @ts-expect-error - Temporary fix for elementRef property access
38+
return triggerRef?.current?.elementRef.current ?? null;
39+
}, [triggerRef]);
40+
41+
const getContainer = React.useCallback(() => {
42+
if (containerRef?.current && containerRef?.current instanceof HTMLElement) {
43+
return containerRef?.current;
44+
}
45+
46+
// @ts-expect-error - Temporary fix for elementRef property access
47+
return containerRef?.current?.elementRef.current ?? null;
48+
}, [containerRef]);
49+
50+
const show = () => {
51+
if (visibleState) return;
52+
53+
setVisibleState(true);
54+
onOpenChange?.({
55+
value: true
56+
});
57+
};
58+
59+
const hide = () => {
60+
if (!visibleState) return;
61+
62+
setVisibleState(false);
63+
onOpenChange?.({
64+
value: false
65+
});
66+
67+
setTimeout(() => {
68+
const trigger = getTrigger();
69+
70+
if (trigger) {
71+
focus(trigger);
72+
}
73+
}, 10);
74+
};
75+
76+
const onBeforeEnter = () => {
77+
const container = getContainer();
78+
79+
if (!container) return;
80+
81+
addStyle(container, { position: 'absolute', top: '0' });
82+
alignOverlay();
83+
84+
if (dismissable) {
85+
bindOutsideClickListener();
86+
}
87+
88+
bindScrollListener();
89+
bindResizeListener();
90+
91+
if (autoZIndex) {
92+
// Fix
93+
ZIndex.set('overlay', container, baseZIndex + 10);
94+
}
95+
96+
overlayEventListeners.current = (e: unknown) => {
97+
const event = e as Event;
98+
99+
if (container.contains(event.target as Node)) {
100+
selfClick.current = true;
101+
}
102+
};
103+
104+
OverlayEventBus.on('overlay-click', overlayEventListeners.current);
105+
106+
if (closeOnEscape) {
107+
bindDocumentKeyDownListener();
108+
}
109+
};
110+
111+
const onLeave = () => {
112+
unbindOutsideClickListener();
113+
unbindScrollListener();
114+
unbindResizeListener();
115+
unbindDocumentKeyDownListener();
116+
117+
if (overlayEventListeners.current) {
118+
OverlayEventBus.off('overlay-click', overlayEventListeners.current);
119+
overlayEventListeners.current = null;
120+
}
121+
122+
hide();
123+
};
124+
125+
const onAfterLeave = () => {
126+
const container = getContainer();
127+
128+
if (autoZIndex && container) {
129+
ZIndex.clear(container);
130+
}
131+
};
132+
133+
const alignOverlay = () => {
134+
const container = getContainer();
135+
136+
const trigger = getTrigger();
137+
138+
if (!trigger || !container) return;
139+
140+
absolutePosition(container, trigger, false);
141+
142+
const containerOffset = getOffset(container);
143+
const targetOffset = getOffset(trigger);
144+
let arrowLeft = 0;
145+
146+
if (Number(containerOffset.left) < Number(targetOffset.left)) {
147+
arrowLeft = Number(targetOffset.left) - Number(containerOffset.left);
148+
}
149+
150+
container.style.setProperty($dt('popover.arrow.left').name, `${arrowLeft}px`);
151+
152+
if (containerOffset.top < targetOffset.top) {
153+
addClass(container, 'p-popover-flipped');
154+
container.setAttribute('data-p-popover-flipped', 'true');
155+
}
156+
};
157+
158+
const onContentKeydown = (event: React.KeyboardEvent<HTMLDivElement>) => {
159+
if (event.code === 'Escape' && closeOnEscape) {
160+
hide();
161+
}
162+
};
163+
164+
const bindOutsideClickListener = () => {
165+
if (!outsideClickListener.current && isClient()) {
166+
outsideClickListener.current = (event: unknown) => {
167+
const clickEvent = event as MouseEvent;
168+
169+
const container = getContainer();
170+
171+
if (visibleState && !(clickEvent.target === container || container?.contains(clickEvent.target as Node))) {
172+
hide();
173+
}
174+
175+
selfClick.current = false;
176+
};
177+
178+
document.addEventListener('click', outsideClickListener.current);
179+
}
180+
};
181+
182+
const unbindOutsideClickListener = () => {
183+
if (outsideClickListener.current) {
184+
document.removeEventListener('click', outsideClickListener.current);
185+
outsideClickListener.current = null;
186+
selfClick.current = false;
187+
}
188+
};
189+
190+
const bindDocumentKeyDownListener = () => {
191+
if (!documentKeydownListener.current) {
192+
documentKeydownListener.current = (event: unknown) => {
193+
const keyboardEvent = event as KeyboardEvent;
194+
195+
if (keyboardEvent.code === 'Escape' && closeOnEscape) {
196+
hide();
197+
}
198+
};
199+
200+
window.document.addEventListener('keydown', documentKeydownListener.current);
201+
}
202+
};
203+
204+
const unbindDocumentKeyDownListener = () => {
205+
if (documentKeydownListener.current) {
206+
window.document.removeEventListener('keydown', documentKeydownListener.current);
207+
documentKeydownListener.current = null;
208+
}
209+
};
210+
211+
const bindScrollListener = () => {
212+
if (!scrollHandler.current) {
213+
scrollHandler.current = new ConnectedOverlayScrollHandler(getTrigger() ?? null, () => {
214+
if (visibleState) {
215+
hide();
216+
}
217+
});
218+
}
219+
220+
scrollHandler.current.bindScrollListener();
221+
};
222+
223+
const unbindScrollListener = () => {
224+
if (scrollHandler.current) {
225+
scrollHandler.current.unbindScrollListener();
226+
}
227+
};
228+
229+
const bindResizeListener = () => {
230+
if (!resizeListener.current) {
231+
resizeListener.current = () => {
232+
if (visibleState && !isTouchDevice()) {
233+
alignOverlay();
234+
}
235+
};
236+
237+
window.addEventListener('resize', resizeListener.current);
238+
}
239+
};
240+
241+
const unbindResizeListener = () => {
242+
if (resizeListener.current) {
243+
window.removeEventListener('resize', resizeListener.current);
244+
resizeListener.current = null;
245+
}
246+
};
247+
248+
const onOverlayClick = (event: Event) => {
249+
OverlayEventBus.emit('overlay-click', {
250+
originalEvent: event,
251+
target: getTrigger()
252+
});
253+
};
254+
255+
const createStyle = () => {
256+
if (!breakpoints || !styleElement.current) {
257+
styleElement.current = document.createElement('style');
258+
styleElement.current.type = 'text/css';
259+
setAttribute(styleElement.current, 'nonce', 'nonce');
260+
document.head.appendChild(styleElement.current);
261+
262+
let innerHTML = '';
263+
264+
for (const breakpoint in breakpoints) {
265+
innerHTML += `
266+
@media screen and (max-width: ${breakpoint}) {
267+
.p-popover {
268+
width: ${breakpoints[breakpoint]} !important;
269+
}
270+
}
271+
`;
272+
}
273+
274+
styleElement.current.innerHTML = innerHTML;
275+
}
276+
};
277+
278+
const destroyStyle = () => {
279+
if (styleElement.current) {
280+
document.head.removeChild(styleElement.current);
281+
styleElement.current = null;
282+
}
283+
};
284+
285+
React.useEffect(() => {
286+
if (open) {
287+
setTimeout(() => {
288+
show();
289+
}, 0);
290+
} else {
291+
hide();
292+
}
293+
}, [open]);
294+
295+
React.useEffect(() => {
296+
if (defaultOpen) {
297+
setTimeout(() => {
298+
show();
299+
}, 0);
300+
} else {
301+
hide();
302+
}
303+
}, [defaultOpen]);
304+
305+
useMountEffect(() => {
306+
if (breakpoints) {
307+
createStyle();
308+
}
309+
});
310+
311+
useUnmountEffect(() => {
312+
if (dismissable) {
313+
unbindOutsideClickListener();
314+
}
315+
316+
if (scrollHandler.current) {
317+
scrollHandler.current.destroy();
318+
scrollHandler.current = null;
319+
}
320+
321+
destroyStyle();
322+
unbindResizeListener();
323+
324+
const container = getContainer();
325+
326+
if (container && autoZIndex) {
327+
ZIndex.clear(container);
328+
}
329+
330+
if (overlayEventListeners.current) {
331+
OverlayEventBus.off('overlay-click', overlayEventListeners.current);
332+
overlayEventListeners.current = null;
333+
}
334+
});
335+
336+
return {
337+
state,
338+
show,
339+
hide,
340+
onBeforeEnter,
341+
onAfterLeave,
342+
onOverlayClick,
343+
onLeave,
344+
onContentKeydown,
345+
triggerRef,
346+
containerRef
347+
};
348+
}
349+
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { createOptionalContext } from '@primereact/core/utils';
2+
import type { PopoverInstance } from '@primereact/types/shared/popover';
3+
4+
export const [PopoverProvider, usePopoverContext] = createOptionalContext<PopoverInstance>();
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import * as HeadlessPopover from '@primereact/headless/popover';
2+
import type { PopoverProps } from '@primereact/types/shared/popover';
3+
4+
export const defaultProps: PopoverProps = {
5+
...HeadlessPopover.defaultProps
6+
};

packages/primereact/src/popover/Popover.test.ts

Whitespace-only changes.

0 commit comments

Comments
 (0)