@vectara/vectara-ui
Version:
Vectara's design system, codified as a React and Sass component library
187 lines (186 loc) • 9.38 kB
JavaScript
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 }))] })) }))) })] }));
};