UNPKG

@vectara/vectara-ui

Version:

Vectara's design system, codified as a React and Sass component library

187 lines (186 loc) 9.38 kB
var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime"; import { cloneElement, useEffect, useRef, useState } from "react"; import classNames from "classnames"; import { VuiPortal } from "../portal/Portal"; import { FocusOn } from "react-focus-on"; import { VuiItemsInput, VuiNumberInput, VuiTextInput } from "../form"; const calculatePopoverPosition = (button, anchorOptions) => { if (!button) return undefined; const { anchorSide, offsetX = 0, offsetY = 0 } = anchorOptions; const { left, right, width, height, top, bottom } = button.getBoundingClientRect(); if (anchorSide === "rightUp") { // Anchor popover to the right side of the button, extending upwards. const adjustedTop = top + height + document.documentElement.scrollTop; const adjustedLeft = left + width + offsetX; return { top: `${adjustedTop}px`, left: `${adjustedLeft}px` }; } if (anchorSide === "rightDown") { // Anchor popover to the right side of the button, extending downwards. const adjustedTop = top - offsetY + document.documentElement.scrollTop; const adjustedLeft = left + width + offsetX; return { top: `${adjustedTop}px`, left: `${adjustedLeft}px` }; } if (anchorSide === "leftUp") { // Anchor popover to the left side of the button, extending upwards. const adjustedTop = top + height + document.documentElement.scrollTop; const adjustedRight = document.documentElement.clientWidth - left + offsetX; return { top: `${adjustedTop}px`, right: `${adjustedRight}px` }; } if (anchorSide === "leftDown") { // Anchor popover to the left side of the button, extending downwards. const adjustedTop = top - offsetY + document.documentElement.scrollTop; const adjustedRight = document.documentElement.clientWidth - left + offsetX; return { top: `${adjustedTop}px`, right: `${adjustedRight}px` }; } if (anchorSide === "upLeft") { // Anchor popover above the button, aligned to the left edge. const adjustedBottom = document.documentElement.clientHeight - top + offsetY; return { bottom: `${adjustedBottom}px`, left: `${left}px` }; } if (anchorSide === "upRight") { // Anchor popover above the button, aligned to the right edge. const adjustedBottom = document.documentElement.clientHeight - top + offsetY; const adjustedRight = document.documentElement.clientWidth - right; return { bottom: `${adjustedBottom}px`, right: `${adjustedRight}px` }; } const adjustedTop = bottom + offsetY + document.documentElement.scrollTop; if (anchorSide === "left") { return { top: `${adjustedTop}px`, left: `${left}px` }; } // Position against the right side of the element. const adjustedRight = document.documentElement.clientWidth - right; return { top: `${adjustedTop}px`, right: `${adjustedRight}px` }; }; export const VuiPopover = (_a) => { var { button: originalButton, children, className, header, isOpen, setIsOpen, padding, anchorSide = "right", anchorOffsetX = 2, anchorOffsetY = 2, onClickButton } = _a, rest = __rest(_a, ["button", "children", "className", "header", "isOpen", "setIsOpen", "padding", "anchorSide", "anchorOffsetX", "anchorOffsetY", "onClickButton"]); const returnFocusElRef = useRef(null); const buttonRef = useRef(null); const [, setPositionMarker] = useState(0); const [showTransition, setShowTransition] = useState(false); // Uncouple open state from visibile state so that when the popover is closed, // we can set the button to be unselected immediately while the popover content // is still transitioning out. const [isContentVisible, setIsContentVisible] = useState(false); const buttonProps = { ref: (node) => { buttonRef.current = node; } }; const isButtonAnInput = Boolean([VuiTextInput, VuiNumberInput, VuiItemsInput].find((type) => type === originalButton.type)); if (isButtonAnInput) { buttonProps.onKeyDown = (e) => { if (e.code !== "Tab" && e.code !== "Escape" && !e.metaKey && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.repeat) { setIsOpen(true); e.preventDefault(); } }; buttonProps.onClick = (e) => { onClickButton === null || onClickButton === void 0 ? void 0 : onClickButton(e); setIsOpen(!isOpen); }; } else { // Assume it's a VUI button component of some sort. buttonProps.isSelected = isOpen; buttonProps.onClick = (e) => { onClickButton === null || onClickButton === void 0 ? void 0 : onClickButton(e); setIsOpen(!isOpen); }; } const button = cloneElement(originalButton, buttonProps); useEffect(() => { const updatePosition = () => { // Force a re-render when the window resizes. setPositionMarker(Date.now()); }; window.addEventListener("resize", updatePosition); // Mostly defensive to prevent weird bugs where the popover ends // up being rendered partially off-screen. window.addEventListener("scroll", updatePosition); return () => { window.removeEventListener("resize", updatePosition); window.removeEventListener("scroll", updatePosition); }; }, []); useEffect(() => { var _a; if (isOpen) { returnFocusElRef.current = document.activeElement; setIsContentVisible(true); requestAnimationFrame(() => { setShowTransition(true); }); } else { (_a = returnFocusElRef.current) === null || _a === void 0 ? void 0 : _a.focus(); returnFocusElRef.current = null; setShowTransition(false); // Wait for the transition to complete before unmounting. // This duration should match the CSS transition speed. window.setTimeout(() => { setIsContentVisible(false); }, 200); } }, [isOpen]); // Allow contents to respond to blur events before unmounting, and also // enable focus to properly return to the button when the user clicks // outside of the popover. const onCloseDelayed = () => { window.setTimeout(() => { setIsOpen(false); }, 0); }; // Always keep menu position up to date. If we tried to cache this inside // a useEffect based on isOpen then there'd be a flicker if the width // of the button changes. const position = calculatePopoverPosition(buttonRef.current, { anchorSide, offsetX: anchorOffsetX, offsetY: anchorOffsetY }); const classes = classNames("vuiPopover", className, { "vuiPopover-isLoaded": showTransition, "vuiPopover--rightUp": anchorSide === "rightUp", "vuiPopover--leftUp": anchorSide === "leftUp", "vuiPopover--upLeft": anchorSide === "upLeft", "vuiPopover--upRight": anchorSide === "upRight" }); const contentClasses = classNames("vuiPopoverContent", { "vuiPopoverContent--padding": padding === true, "vuiPopoverContent--paddingNone": padding === "none" }); return (_jsxs(_Fragment, { children: [button, _jsx(VuiPortal, { children: (isOpen || isContentVisible || showTransition) && position && (_jsx(FocusOn // Disable the focus guard as soon as the popover begins closing // so it doesn't intercept clicks while transitioning out. , Object.assign({ // Disable the focus guard as soon as the popover begins closing // so it doesn't intercept clicks while transitioning out. enabled: isOpen, onEscapeKey: onCloseDelayed, onClickOutside: onCloseDelayed, // Enable manual focus return to work. returnFocus: false, // Enable focus on contents when it's open, // but enable manual focus return to work when it's closed. autoFocus: isOpen, // Enable scrolling of the page. scrollLock: false, // Enable scrolling of the page. preventScrollOnFocus: false }, { children: _jsxs("div", Object.assign({ className: classes, style: position }, rest, { children: [header && typeof header === "string" ? _jsx("div", Object.assign({ className: "vuiPopoverTitle" }, { children: header })) : header, children && _jsx("div", Object.assign({ className: contentClasses }, { children: children }))] })) }))) })] })); };