UNPKG

7.5 kBPlain TextView Raw
1import * as React from "react";
2import { createPopper, Instance, State } from "@popperjs/core";
3import {
4 SealedInitialState,
5 useSealedState,
6} from "reakit-utils/useSealedState";
7import { useIsomorphicEffect } from "reakit-utils/useIsomorphicEffect";
8import { shallowEqual } from "reakit-utils/shallowEqual";
9import { isUA } from "reakit-utils/dom";
10import {
11 DialogState,
12 DialogActions,
13 DialogInitialState,
14 useDialogState,
15 DialogStateReturn,
16} from "../Dialog/DialogState";
17
18const isSafari = isUA("Mac") && !isUA("Chrome") && isUA("Safari");
19
20type Placement =
21 | "auto-start"
22 | "auto"
23 | "auto-end"
24 | "top-start"
25 | "top"
26 | "top-end"
27 | "right-start"
28 | "right"
29 | "right-end"
30 | "bottom-end"
31 | "bottom"
32 | "bottom-start"
33 | "left-end"
34 | "left"
35 | "left-start";
36
37export type PopoverState = DialogState & {
38 /**
39 * The reference element.
40 */
41 unstable_referenceRef: React.RefObject<HTMLElement | null>;
42 /**
43 * The popover element.
44 * @private
45 */
46 unstable_popoverRef: React.RefObject<HTMLElement | null>;
47 /**
48 * The arrow element.
49 * @private
50 */
51 unstable_arrowRef: React.RefObject<HTMLElement | null>;
52 /**
53 * Popover styles.
54 * @private
55 */
56 unstable_popoverStyles: React.CSSProperties;
57 /**
58 * Arrow styles.
59 * @private
60 */
61 unstable_arrowStyles: React.CSSProperties;
62 /**
63 * `placement` passed to the hook.
64 * @private
65 */
66 unstable_originalPlacement: Placement;
67 /**
68 * @private
69 */
70 unstable_update: () => boolean;
71 /**
72 * Actual `placement`.
73 */
74 placement: Placement;
75};
76
77export type PopoverActions = DialogActions & {
78 /**
79 * Change the `placement` state.
80 */
81 place: React.Dispatch<React.SetStateAction<Placement>>;
82};
83
84export type PopoverInitialState = DialogInitialState &
85 Partial<Pick<PopoverState, "placement">> & {
86 /**
87 * Whether or not the popover should have `position` set to `fixed`.
88 */
89 unstable_fixed?: boolean;
90 /**
91 * Flip the popover's placement when it starts to overlap its reference
92 * element.
93 */
94 unstable_flip?: boolean;
95 /**
96 * Offset between the reference and the popover: [main axis, alt axis]. Should not be combined with `gutter`.
97 */
98 unstable_offset?: [number | string, number | string];
99 /**
100 * Offset between the reference and the popover on the main axis. Should not be combined with `unstable_offset`.
101 */
102 gutter?: number;
103 /**
104 * Prevents popover from being positioned outside the boundary.
105 */
106 unstable_preventOverflow?: boolean;
107 };
108
109export type PopoverStateReturn = DialogStateReturn &
110 PopoverState &
111 PopoverActions;
112
113function applyStyles(styles?: Partial<CSSStyleDeclaration>) {
114 return (prevStyles: React.CSSProperties) => {
115 if (styles && !shallowEqual(prevStyles, styles)) {
116 return styles as React.CSSProperties;
117 }
118 return prevStyles;
119 };
120}
121
122export function usePopoverState(
123 initialState: SealedInitialState<PopoverInitialState> = {}
124): PopoverStateReturn {
125 const {
126 gutter = 12,
127 placement: sealedPlacement = "bottom",
128 unstable_flip: flip = true,
129 unstable_offset: sealedOffset,
130 unstable_preventOverflow: preventOverflow = true,
131 unstable_fixed: fixed = false,
132 modal = false,
133 ...sealed
134 } = useSealedState(initialState);
135
136 const popper = React.useRef<Instance | null>(null);
137 const referenceRef = React.useRef<HTMLElement>(null);
138 const popoverRef = React.useRef<HTMLElement>(null);
139 const arrowRef = React.useRef<HTMLElement>(null);
140
141 const [originalPlacement, place] = React.useState(sealedPlacement);
142 const [placement, setPlacement] = React.useState(sealedPlacement);
143 const [offset] = React.useState(sealedOffset || [0, gutter]);
144 const [popoverStyles, setPopoverStyles] = React.useState<React.CSSProperties>(
145 {
146 position: "fixed",
147 left: "100%",
148 top: "100%",
149 }
150 );
151 const [arrowStyles, setArrowStyles] = React.useState<React.CSSProperties>({});
152
153 const dialog = useDialogState({ modal, ...sealed });
154
155 const update = React.useCallback(() => {
156 if (popper.current) {
157 popper.current.forceUpdate();
158 return true;
159 }
160 return false;
161 }, []);
162
163 const updateState = React.useCallback((state: Partial<State>) => {
164 if (state.placement) {
165 setPlacement(state.placement);
166 }
167 if (state.styles) {
168 setPopoverStyles(applyStyles(state.styles.popper));
169 if (arrowRef.current) {
170 setArrowStyles(applyStyles(state.styles.arrow));
171 }
172 }
173 }, []);
174
175 useIsomorphicEffect(() => {
176 if (referenceRef.current && popoverRef.current) {
177 popper.current = createPopper(referenceRef.current, popoverRef.current, {
178 // https://popper.js.org/docs/v2/constructors/#options
179 placement: originalPlacement,
180 strategy: fixed ? "fixed" : "absolute",
181 // Safari needs styles to be applied in the first render, otherwise
182 // hovering over the popover when it gets visible for the first time
183 // will change its dimensions unexpectedly.
184 onFirstUpdate: isSafari ? updateState : undefined,
185 modifiers: [
186 {
187 // https://popper.js.org/docs/v2/modifiers/event-listeners/
188 name: "eventListeners",
189 enabled: dialog.visible,
190 },
191 {
192 // https://popper.js.org/docs/v2/modifiers/apply-styles/
193 name: "applyStyles",
194 enabled: false,
195 },
196 {
197 // https://popper.js.org/docs/v2/modifiers/flip/
198 name: "flip",
199 enabled: flip,
200 options: { padding: 8 },
201 },
202 {
203 // https://popper.js.org/docs/v2/modifiers/offset/
204 name: "offset",
205 options: { offset },
206 },
207 {
208 // https://popper.js.org/docs/v2/modifiers/prevent-overflow/
209 name: "preventOverflow",
210 enabled: preventOverflow,
211 options: {
212 tetherOffset: () => arrowRef.current?.clientWidth || 0,
213 },
214 },
215 {
216 // https://popper.js.org/docs/v2/modifiers/arrow/
217 name: "arrow",
218 enabled: !!arrowRef.current,
219 options: { element: arrowRef.current },
220 },
221 {
222 // https://popper.js.org/docs/v2/modifiers/#custom-modifiers
223 name: "updateState",
224 phase: "write",
225 requires: ["computeStyles"],
226 enabled: dialog.visible && process.env.NODE_ENV !== "test",
227 fn: ({ state }) => updateState(state),
228 },
229 ],
230 });
231 }
232 return () => {
233 if (popper.current) {
234 popper.current.destroy();
235 popper.current = null;
236 }
237 };
238 }, [originalPlacement, fixed, dialog.visible, flip, offset, preventOverflow]);
239
240 // Ensure that the popover will be correctly positioned with an additional
241 // update.
242 React.useEffect(() => {
243 if (!dialog.visible) return undefined;
244 const id = window.requestAnimationFrame(() => {
245 popper.current?.forceUpdate();
246 });
247 return () => {
248 window.cancelAnimationFrame(id);
249 };
250 }, [dialog.visible]);
251
252 return {
253 ...dialog,
254 unstable_referenceRef: referenceRef,
255 unstable_popoverRef: popoverRef,
256 unstable_arrowRef: arrowRef,
257 unstable_popoverStyles: popoverStyles,
258 unstable_arrowStyles: arrowStyles,
259 unstable_update: update,
260 unstable_originalPlacement: originalPlacement,
261 placement,
262 place,
263 };
264}