UNPKG

adwaita-web

Version:

A GTK inspired toolkit designed to build awesome web apps

329 lines (328 loc) 9.76 kB
import { createPopper } from "@popperjs/core"; import cx from "clsx"; import React from "react"; import { createPortal, findDOMNode } from "react-dom"; const noop = () => { }; function getInversePlacement(p) { if (p.startsWith("top")) return "bottom"; if (p.startsWith("left")) return "right"; if (p.startsWith("right")) return "left"; return "top"; } class Popover extends React.PureComponent { static defaultProps = { arrow: true, placement: "bottom", align: "right", method: "click", delay: 200, shouldUpdatePlacement: true, onOpen: noop, onClose: noop, onDidOpen: noop, onDidClose: noop }; domNode; isDomNodeAttached; isEventListening; openTimeout; closeTimeout; triggerRef; popoverRef; arrowRef; observer; popper; state; constructor(props) { super(props); this.domNode = document.createElement("div"); this.domNode.className = "Popover__domNode"; this.isDomNodeAttached = false; this.isEventListening = false; this.openTimeout = void 0; this.closeTimeout = void 0; this.state = { open: false, closing: false, actualPlacement: props.placement, styles: {} }; this.triggerRef = React.createRef(); this.popoverRef = React.createRef(); this.arrowRef = React.createRef(); this.observer = new ResizeObserver(this.onContentResize); } componentWillUnmount() { this.detachDomNode(); this.detachPopper(); } componentDidMount() { this.attachPopper(); if (this.props.shouldAttachEarly) this.attachDomNode(); } componentDidUpdate(prevProps, prevState) { if (prevProps.open !== this.props.open || prevState.open !== this.state.open) { if (this.props.shouldUpdatePlacement && this.popper) this.popper.update(); } } attachDomNode() { if (!this.isDomNodeAttached) { document.body.append(this.domNode); document.addEventListener("click", this.onDocumentClick); this.isDomNodeAttached = true; this.forceUpdate(); } } detachDomNode() { if (this.isDomNodeAttached) { document.body.removeChild(this.domNode); document.removeEventListener("click", this.onDocumentClick); this.isDomNodeAttached = false; } } attachPopper() { if (!this.popoverRef.current || !this.triggerRef.current) return; if (this.popper) return; this.popper = createPopper(this.triggerRef.current, this.popoverRef.current, this.getPopperOptions()); } detachPopper() { if (this.popper) { this.popper.destroy(); this.popper = void 0; } } updatePopperOptions() { if (!this.popper) return; this.popper.setOptions(this.getPopperOptions()); } getPopperOptions() { const hasArrow = this.props.arrow; const isOpen = this.isOpen(); this.isEventListening = isOpen; return { placement: this.props.placement, modifiers: [ { name: "arrow", enabled: hasArrow, options: { element: this.arrowRef.current, padding: 15 } }, { name: "offset", options: { offset: [0, hasArrow ? 10 : 0] } }, { name: "preventOverflow", options: { altAxis: true, padding: 10 } }, { name: "eventListeners", enabled: isOpen }, { name: "updateComponentState", enabled: true, phase: "write", fn: this.onUpdatePopper } ] }; } onContentResize = () => { if (this.popper) this.popper.update(); }; onRefPopover = (ref) => { if (!ref) { if (this.popoverRef.current) { this.observer.unobserve(this.popoverRef.current); this.popoverRef.current = null; } return; } this.popoverRef.current = ref; this.observer.observe(this.popoverRef.current); }; onDocumentClick = (ev) => { if (this.props.method !== "click" && this.props.method !== "click-controlled") return; if (!this.isOpen()) return; if (!(ev.target && this.triggerRef.current?.contains(ev.target) || ev.target && this.popoverRef.current?.contains(ev.target))) this.close(); }; onTransitionEnd = () => { this.setState({ closing: false }); if (this.state.open && this.props.onDidOpen) this.props.onDidOpen(); else if (this.props.onDidClose) this.props.onDidClose(); }; onUpdatePopper = ({ state }) => { if (this.state.actualPlacement !== state.placement) { this.setState({ actualPlacement: state.placement }); } if (this.props.width) { const trigger = state.elements.reference; const rect = trigger.getBoundingClientRect(); if (this.props.width === "trigger") { const currentWidth = this.state.styles.width; const newWidth = rect.width - 1; if (currentWidth !== newWidth) this.setState({ styles: { width: newWidth } }); } else if (this.props.width === "trigger-min") { const currentWidth = this.state.styles.minWidth; const newWidth = rect.width - 1; if (currentWidth !== newWidth) this.setState({ styles: { minWidth: newWidth } }); } } }; onClick = (ev) => { if (!this.triggerRef.current?.contains(ev.target)) return; if (this.isOpen()) this.close(); else this.open(); }; onMouseOver = () => { if (this.closeTimeout) { window.clearTimeout(this.closeTimeout); this.closeTimeout = void 0; } if (this.state.open === false) { if (!this.props.delay) { this.open(); } else { this.openTimeout = window.setTimeout(() => { this.open(); }, this.props.delay); } } }; onMouseOut = () => { if (this.openTimeout) { window.clearTimeout(this.openTimeout); this.openTimeout = void 0; } if (this.state.open) { if (!this.props.delay) { this.close(); } else { this.closeTimeout = window.setTimeout(() => { this.close(); }, this.props.delay); } } }; isControlled = () => { return "open" in this.props; }; open = () => { if (this.props.open && this.state.open) return; if (this.isControlled()) { if (this.props.open === false) { if (this.props.onOpen) return this.props.onOpen(); return; } } this.attachDomNode(); this.updatePopperOptions(); this.openTimeout = void 0; this.setState({ open: true }); if (!this.isControlled()) { if (this.props.onOpen) return this.props.onOpen(); } }; close = () => { if (this.isControlled()) { if (this.props.open === true) { if (this.props.onClose) return this.props.onClose(); return; } } this.updatePopperOptions(); this.setState({ open: false, closing: true }); if (!this.isControlled()) { if (this.props.onClose) this.props.onClose(); } }; isOpen() { return this.props.open ?? this.state.open; } getContent() { return !this.isDomNodeAttached ? null : typeof this.props.content === "function" ? this.props.content() : this.props.content; } getEventListeners() { const { method } = this.props; return method === "mouseover" ? { onMouseOver: this.onMouseOver, onMouseOut: this.onMouseOut } : method === "click" ? { onClick: this.onClick } : {}; } render() { const { method, arrow, children, className } = this.props; const { actualPlacement, styles, closing } = this.state; const open = this.isOpen(); const trigger = children; if (this.props.open && !this.state.open) setTimeout(this.open, 0); if (open !== this.isEventListening) this.updatePopperOptions(); const triggerProps = trigger ? typeof trigger === "object" && trigger !== null ? "props" in trigger ? trigger.props : {} : {} : {}; const eventListeners = this.getEventListeners(); const props = { ...triggerProps, ...eventListeners, className: cx(triggerProps.className, open ? "with-popover" : void 0, open ? `popover-${actualPlacement}` : void 0), ref: (node) => { if (node) this.triggerRef.current = findDOMNode(node); if (typeof trigger === "object" && trigger && "ref" in trigger && typeof trigger["ref"] === "function") trigger.ref(node); } }; const arrowPlacement = getInversePlacement(actualPlacement ?? "top"); const popoverEventListeners = method === "mouseover" ? eventListeners : void 0; const popoverClassName = cx("Popover popover", className, actualPlacement, arrow ? `arrow-${arrowPlacement}` : void 0, { open, arrow, closing }); return /* @__PURE__ */ React.createElement(React.Fragment, null, typeof trigger === "object" && trigger !== null && !Array.isArray(trigger) && React.cloneElement(trigger, props), createPortal(/* @__PURE__ */ React.createElement("div", { ref: this.onRefPopover, className: popoverClassName, onTransitionEnd: this.onTransitionEnd, ...popoverEventListeners }, /* @__PURE__ */ React.createElement("div", { className: "Popover__wrapper" }, arrow && /* @__PURE__ */ React.createElement("div", { className: cx("Popover__arrow", arrowPlacement), ref: this.arrowRef }), /* @__PURE__ */ React.createElement("div", { className: "Popover__container", style: styles }, /* @__PURE__ */ React.createElement("div", { className: "Popover__content" }, this.getContent())))), this.domNode)); } } export { Popover };