@kiwicom/orbit-components
Version:
Orbit-components is a React component library which provides developers with the easiest possible way of building Kiwi.com’s products.
334 lines (331 loc) • 16.2 kB
JavaScript
"use client";
import * as React from "react";
import cx from "clsx";
import { ModalContext } from "./ModalContext";
import ModalCloseButton from "./ModalCloseButton";
import { SIZES, CLOSE_BUTTON_DATA_TEST } from "./consts";
import KEY_CODE_MAP from "../common/keyMaps";
import useRandomId from "../hooks/useRandomId";
import useMediaQuery from "../hooks/useMediaQuery";
import FOCUSABLE_ELEMENT_SELECTORS from "../hooks/useFocusTrap/consts";
import usePrevious from "../hooks/usePrevious";
import useLockScrolling from "../hooks/useLockScrolling";
import useTheme from "../hooks/useTheme";
const maxWidthClasses = {
[SIZES.EXTRASMALL]: "max-w-modal-extra-small",
[SIZES.SMALL]: "max-w-modal-small",
[SIZES.NORMAL]: "max-w-modal-normal",
[SIZES.LARGE]: "max-w-modal-large",
[SIZES.EXTRALARGE]: "max-w-modal-extra-large",
largeMobile: {
[SIZES.EXTRASMALL]: "lm:max-w-modal-extra-small",
[SIZES.SMALL]: "lm:max-w-modal-small",
[SIZES.NORMAL]: "lm:max-w-modal-normal",
[SIZES.LARGE]: "lm:max-w-modal-large",
[SIZES.EXTRALARGE]: "lm:max-w-modal-extra-large"
},
footer: {
[SIZES.EXTRASMALL]: "lm:[&_.orbit-modal-footer]:max-w-modal-extra-small",
[SIZES.SMALL]: "lm:[&_.orbit-modal-footer]:max-w-modal-small",
[SIZES.NORMAL]: "lm:[&_.orbit-modal-footer]:max-w-modal-normal",
[SIZES.LARGE]: "lm:[&_.orbit-modal-footer]:max-w-modal-large",
[SIZES.EXTRALARGE]: "lm:[&_.orbit-modal-footer]:max-w-modal-extra-large"
}
};
const OFFSET = 40;
const Modal = /*#__PURE__*/React.forwardRef(({
size = SIZES.NORMAL,
scrollingElementRef,
children,
onClose,
autoFocus = true,
fixedFooter = false,
isMobileFullPage = false,
preventOverlayClose = false,
onScroll,
hasCloseButton = true,
mobileHeader = true,
disableAnimation = false,
dataTest,
id,
labelClose = "Close",
lockScrolling = true
}, ref) => {
const [loaded, setLoaded] = React.useState(false);
const [scrolled, setScrolled] = React.useState(false);
const [fullyScrolled, setFullyScrolled] = React.useState(false);
const [hasModalTitle, setHasModalTitle] = React.useState(false);
const [hasModalSection, setHasModalSection] = React.useState(false);
const [clickedModalBody, setClickedModalBody] = React.useState(false);
const [fixedClose, setFixedClose] = React.useState(false);
const [focusTriggered, setFocusTriggered] = React.useState(false);
const [modalWidth, setModalWidth] = React.useState(0);
const [footerHeight, setFooterHeight] = React.useState(0);
const [firstFocusableEl, setFirstFocusableEl] = React.useState(null);
const [lastFocusableEl, setLastFocusableEl] = React.useState(null);
const modalContent = React.useRef(null);
const modalBody = React.useRef(null);
const modalTitleID = useRandomId();
const theme = useTheme();
const {
isLargeMobile
} = useMediaQuery();
const scrollingElement = React.useRef(null);
const setScrollingElementRefs = React.useCallback(node => {
scrollingElement.current = node;
if (scrollingElementRef) {
if (typeof scrollingElementRef === "function") {
scrollingElementRef(node);
} else {
// @ts-expect-error TODO
// eslint-disable-next-line no-param-reassign
scrollingElementRef.current = node;
}
}
}, [scrollingElementRef]);
useLockScrolling(scrollingElement, lockScrolling, [isLargeMobile]);
const modalContentRef = React.useCallback(node => {
modalContent.current = node;
if (!isLargeMobile) setScrollingElementRefs(node);
}, [isLargeMobile, setScrollingElementRefs]);
const modalBodyRef = React.useCallback(node => {
modalBody.current = node;
if (isLargeMobile) setScrollingElementRefs(node);
}, [isLargeMobile, setScrollingElementRefs]);
const prevChildren = usePrevious(children);
const setDimensions = () => {
const content = modalContent.current;
if (!content) return;
const footerEl = content.querySelector(".orbit-modal-footer");
const contentDimensions = content.getBoundingClientRect();
setModalWidth(contentDimensions.width);
if (footerEl?.clientHeight) {
setFooterHeight(footerEl.clientHeight);
}
};
const setFirstFocus = () => {
if (modalBody.current && autoFocus) {
modalBody.current.focus();
}
};
const decideFixedFooter = () => {
if (!modalContent.current || !modalBody.current) return;
// if the content height is smaller than window height, we need to explicitly set fullyScrolled to true
const content = modalContent.current;
const body = modalBody.current;
const contentHeight = content.scrollHeight > content.offsetHeight + OFFSET ? content.offsetHeight : content.scrollHeight;
// when scrollHeight + topPadding - scrollingElementHeight is smaller than or equal to window height
setFullyScrolled(contentHeight + OFFSET - body.scrollTop <= window.innerHeight);
};
const manageFocus = React.useCallback(() => {
if (!focusTriggered || !modalContent.current) return;
const focusableElements = modalContent.current.querySelectorAll(FOCUSABLE_ELEMENT_SELECTORS);
if (focusableElements.length > 0) {
setFirstFocusableEl(focusableElements[0]);
setLastFocusableEl(focusableElements[focusableElements.length - 1]);
}
}, [focusTriggered]);
const keyboardHandler = event => {
if (event.keyCode !== KEY_CODE_MAP.TAB) return;
if (!focusTriggered) {
setFocusTriggered(true);
manageFocus();
}
if (event.shiftKey && (document.activeElement === firstFocusableEl || document.activeElement === modalBody.current)) {
event.preventDefault();
lastFocusableEl?.focus();
} else if (!event.shiftKey && document.activeElement === lastFocusableEl) {
event.preventDefault();
firstFocusableEl?.focus();
}
};
const handleKeyDown = event => {
if (onClose && event.key === "Escape") {
event.stopPropagation();
onClose(event);
}
keyboardHandler(event);
};
const handleClickOutside = event => {
const clickedOutside = onClose && preventOverlayClose === false && !clickedModalBody && modalContent.current && event.target instanceof Element && !modalContent.current.contains(event.target) && (event.target.className.includes("orbit-modal-wrapper") || event.target.className.includes("orbit-modal-body"));
if (clickedOutside && onClose) onClose(event);
setClickedModalBody(false);
};
const setScrollStates = (target, fullScrollOffset, fixCloseOffset, scrollBegin, mobile) => {
const content = modalContent.current;
if (!content) return;
const {
height: contentHeight
} = content.getBoundingClientRect();
/*
Only for desktop, we need to check if the scrollHeight of content is bigger than actual height
if so, we need to you use the contentHeight + padding as bottom scroll point,
otherwise actual scrollHeight of the target is enough.
*/
const scrollHeight = !mobile && target.scrollHeight > contentHeight + 80 ? contentHeight + 80 : target.scrollHeight;
setScrolled(target.scrollTop >= Number(scrollBegin) + (!mobile ? target.scrollTop : 0));
setFixedClose(target.scrollTop >= fixCloseOffset);
// set fullyScrolled state sooner than the exact end of the scroll (with fullScrollOffset value)
setFullyScrolled(fixedFooter && target.scrollTop >= scrollHeight - target.clientHeight - fullScrollOffset);
};
const getScrollTopPoint = mobile => {
const content = modalContent.current;
if (!content) return null;
const headingEl = content.querySelector(".orbit-modal-heading");
if (headingEl) {
const {
top
} = headingEl.getBoundingClientRect();
return top;
}
if (mobile) return OFFSET;
const {
top
} = content.getBoundingClientRect();
return top;
};
const handleScroll = event => {
if (event.target instanceof HTMLDivElement && event.target === modalBody.current) {
if (onScroll) onScroll(event);
setScrollStates(event.target, OFFSET, OFFSET, getScrollTopPoint());
}
};
const handleMobileScroll = event => {
if (onScroll) onScroll(event);
if (event.target instanceof HTMLDivElement && event.target === modalContent.current) {
setScrollStates(event.target, 10, 1, getScrollTopPoint(true), true);
}
};
const handleMouseDown = () => {
/*
This is due to issue where it was possible to close Modal,
even though click started (onMouseDown) in ModalWrapper.
*/
setClickedModalBody(true);
};
const callContextFunctions = React.useCallback(() => {
setDimensions();
decideFixedFooter();
manageFocus();
}, [manageFocus]);
const getScrollPosition = () => {
if (scrollingElement.current) {
return scrollingElement.current.scrollTop;
}
return null;
};
const setScrollPosition = value => {
if (scrollingElement.current) {
scrollingElement.current.scrollTop = value;
}
};
React.useImperativeHandle(ref, () => ({
getScrollPosition,
setScrollPosition,
modalBody,
modalContent
}));
// eslint-disable-next-line consistent-return
React.useEffect(() => {
if (disableAnimation) {
decideFixedFooter();
setDimensions();
setFirstFocus();
} else {
const timer = setTimeout(() => {
setLoaded(true);
decideFixedFooter();
setDimensions();
setFirstFocus();
}, 15);
return () => {
clearTimeout(timer);
};
}
// the Modal can only transition in on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
const handleResize = () => {
setDimensions();
decideFixedFooter();
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
React.useEffect(() => {
if (children !== prevChildren) {
decideFixedFooter();
setDimensions();
}
}, [children, prevChildren]);
const hasCloseContainer = mobileHeader && (hasModalTitle || onClose && hasCloseButton);
const value = React.useMemo(() => ({
setHasModalTitle,
setHasModalSection: () => setHasModalSection(true),
removeHasModalSection: () => setHasModalSection(false),
callContextFunctions,
setFooterHeight,
hasModalSection,
hasMobileHeader: mobileHeader,
isMobileFullPage,
closable: Boolean(onClose),
isInsideModal: true,
titleID: modalTitleID
}), [callContextFunctions, hasModalSection, isMobileFullPage, mobileHeader, onClose, modalTitleID]);
const cssVars = {
...(!isLargeMobile ? {
maxHeight: isMobileFullPage ? "100%" : `calc(100% - ${theme.orbit.spaceXLarge} - ${fixedFooter && Boolean(footerHeight) ? footerHeight : 0}px)`,
bottom: `${(!isMobileFullPage ? parseInt(theme.orbit.spaceXLarge, 10) : 0) + (fixedFooter && Boolean(footerHeight) ? footerHeight : 0)}px`
} : {
"--orbit-modal-footer-height": fixedFooter ? `${footerHeight}px` : "0"
}),
"--orbit-modal-width": `${modalWidth}px`
};
return (
/*#__PURE__*/
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
React.createElement("div", {
className: cx("orbit-modal-body", "z-overlay font-base fixed inset-0 box-border h-full w-full overflow-x-hidden outline-none", !isMobileFullPage && "bg-[black] bg-opacity-50", "lm:overflow-y-auto lm:p-xxl lm:bg-[black] lm:bg-opacity-50")
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
,
tabIndex: 0,
onKeyDown: handleKeyDown,
onScroll: handleScroll,
onClick: handleClickOutside,
"data-test": dataTest,
id: id,
ref: modalBodyRef,
role: "dialog"
// eslint-disable-next-line jsx-a11y/no-autofocus
,
autoFocus: autoFocus,
"aria-modal": "true",
"aria-labelledby": hasModalTitle ? modalTitleID : ""
}, /*#__PURE__*/React.createElement("div", {
className: cx("orbit-modal-wrapper", "fixed mx-auto my-0 box-border flex min-h-full w-full items-start", !isMobileFullPage && "rounded-t-modal-mobile", disableAnimation ? !isMobileFullPage && "top-[32px]" : ["duration-normal transition-[top] ease-in-out", loaded ? !isMobileFullPage && "top-[32px]" : "top-full"], "lm:relative lm:top-0 lm:items-center", maxWidthClasses.largeMobile[size])
}, /*#__PURE__*/React.createElement("div", {
className: cx("orbit-modal-wrapper-content", "font-base bg-elevation-flat shadow-overlay absolute box-border w-full overflow-y-auto overflow-x-hidden", "lm:relative lm:bottom-auto lm:pb-0 lm:overflow-visible", "lm:[&_.orbit-modal-section:last-of-type]:pb-xxl lm:[&_.orbit-modal-section:last-of-type:after]:content-none lm:[&_.orbit-modal-section:last-of-type]:mb-[var(--orbit-modal-footer-height,0px)]", "lm:[&_.orbit-modal-mobile-header]:w-[calc(var(--orbit-modal-width)-48px-theme(spacing.xxl))]", !hasModalSection && "[&_.orbit-modal-header-container]:mb-xl lm:[&_.orbit-modal-header-container]:mb-[var(--orbit-modal-footer-height,0px)]", isMobileFullPage ? "top-0 max-h-full" : ["rounded-t-modal-mobile", "lm:rounded-modal", scrolled && "[&_.orbit-modal-mobile-header]:top-xl"], fixedFooter && footerHeight && ["[&_.orbit-modal-footer]:p-md [&_.orbit-modal-footer]:fixed [&_.orbit-modal-footer]:bottom-0", "[&_.orbit-modal-footer]:duration-fast [&_.orbit-modal-footer]:transition-shadow [&_.orbit-modal-footer]:ease-in-out", fullyScrolled ? "[&_.orbit-modal-footer]:shadow-modal-scrolled" : "[&_.orbit-modal-footer]:shadow-modal", "[&_.orbit-modal-section:last-of-type]:pb-lg [&_.orbit-modal-section:last-of-type]:mb-0"], fixedFooter ? ["lm:[&_.orbit-modal-footer]:!p-xl", fullyScrolled && "lm:[&_.orbit-modal-footer]:absolute"] : "lm[&_.orbit-modal-footer]:p-xl]", fullyScrolled && "lm:[&_.orbit-modal-footer]:shadow-none", scrolled ? ["[&_.orbit-modal-mobile-header]:visible [&_.orbit-modal-mobile-header]:opacity-100", "[&_.orbit-modal-mobile-header]:ease-in-out [&_.orbit-modal-mobile-header]:[transition:visibility_theme(transitionDuration.fast),_opacity_theme(transitionDuration.fast),_top_theme(transitionDuration.normal)]", "lm:[&_.orbit-modal-mobile-header]:top-0"] : "lm:[&_.orbit-modal-mobile-header]:-top-xxl", modalWidth ? "lm:[&_.orbit-modal-footer]:max-w-[var(--orbit-modal-width)]" : maxWidthClasses.footer[size]),
style: cssVars,
onScroll: handleMobileScroll,
ref: modalContentRef,
onMouseDown: handleMouseDown
}, hasCloseContainer && /*#__PURE__*/React.createElement("div", {
className: cx("z-modal-overlay h-form-box-large pointer-events-none right-0 box-border flex w-full items-center justify-end", "duration-fast transition-[shadow,_background-color] ease-in-out", "lm:rounded-none", fixedClose || scrolled ? "lm:top-0 lm:right-auto fixed" : "absolute", !isMobileFullPage && (fixedClose || scrolled) ? "top-[32px]" : "top-0", !isMobileFullPage && "rounded-t-modal-mobile", modalWidth ? "max-w-[var(--orbit-modal-width)]" : maxWidthClasses[size], scrolled && "shadow-fixed bg-white-normal", "[&_+_.orbit-modal-section:first-of-type]:pt-xxxl [&_+_.orbit-modal-section:first-of-type]:bt-0 [&_+_.orbit-modal-section:first-of-type]:m-0", "[&_.orbit-button-primitive]:me-xxs [&_.orbit-button-primitive]:pointer-events-auto", "[&_.orbit-button-primitive_svg]:transition-color [&_.orbit-button-primitive_svg]:duration-fast [&_.orbit-button-primitive_svg]:text-ink-normal [&_.orbit-button-primitive_svg]:ease-in-out", "[&_.orbit-button-primitive:hover_svg]:text-ink-light-hover [&_.orbit-button-primitive:active_svg]:text-ink-light-active"),
"data-test": "CloseContainer"
}, onClose && hasCloseButton && /*#__PURE__*/React.createElement(ModalCloseButton, {
onClick: onClose,
dataTest: CLOSE_BUTTON_DATA_TEST,
title: labelClose
})), /*#__PURE__*/React.createElement(ModalContext.Provider, {
value: value
}, children))))
);
});
Modal.displayName = "Modal";
export default Modal;
export { default as ModalHeader } from "./ModalHeader";
export { default as ModalSection } from "./ModalSection";
export { default as ModalFooter } from "./ModalFooter";