UNPKG

@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.

408 lines (365 loc) 18.1 kB
import * as React from "react"; import styled, { css } from "styled-components"; import { ModalContext } from "./ModalContext"; import { MobileHeader, StyledModalHeader, ModalHeading } from "./ModalHeader"; import { StyledModalFooter } from "./ModalFooter"; import { StyledModalSection } from "./ModalSection"; import ModalCloseButton from "./ModalCloseButton"; import { SIZES, CLOSE_BUTTON_DATA_TEST } from "./consts"; import KEY_CODE_MAP from "../common/keyMaps"; import defaultTheme from "../defaultTheme"; import { StyledButtonPrimitive } from "../primitives/ButtonPrimitive"; import media from "../utils/mediaQuery"; import { right } from "../utils/rtl"; import transition from "../utils/transition"; 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"; const getSizeToken = () => ({ size, theme }) => { const tokens = { [SIZES.EXTRASMALL]: "360px", [SIZES.SMALL]: theme.orbit.widthModalSmall, [SIZES.NORMAL]: theme.orbit.widthModalNormal, [SIZES.LARGE]: theme.orbit.widthModalLarge, [SIZES.EXTRALARGE]: theme.orbit.widthModalExtraLarge }; return tokens[size]; }; const ModalBody = styled.div.withConfig({ displayName: "Modal__ModalBody", componentId: "sc-15ie1vv-0" })(["", ""], ({ theme }) => css(["width:100%;height:100%;position:fixed;top:0;right:0;bottom:0;left:0;z-index:", ";box-sizing:border-box;outline:none;overflow-x:hidden;background-color:rgba(0,0,0,0.5);font-family:", ";-webkit-overflow-scrolling:auto;", ";"], theme.orbit.zIndexModalOverlay, theme.orbit.fontFamily, media.largeMobile(css(["overflow-y:auto;padding:", ";"], theme.orbit.spaceXXLarge)))); // $FlowFixMe: https://github.com/flow-typed/flow-typed/issues/3653#issuecomment-568539198 ModalBody.defaultProps = { theme: defaultTheme }; const ModalWrapper = styled.div.withConfig({ displayName: "Modal__ModalWrapper", componentId: "sc-15ie1vv-1" })(["", ""], ({ isMobileFullPage, disableAnimation, loaded }) => css(["box-sizing:border-box;min-height:100%;display:flex;align-items:flex-start;margin:0 auto;position:fixed;width:100%;border-top-left-radius:", ";border-top-right-radius:", ";", " ", ";"], !isMobileFullPage && "12px", !isMobileFullPage && "12px", disableAnimation ? css(["top:", ";"], !isMobileFullPage && "32px") : css(["transition:", ";top:", ";"], transition(["top"], "normal", "ease-in-out"), loaded ? !isMobileFullPage && "32px" : "100%"), media.largeMobile(css(["position:relative;top:0;max-width:", ";align-items:center;"], getSizeToken)))); // $FlowFixMe: https://github.com/flow-typed/flow-typed/issues/3653#issuecomment-568539198 ModalWrapper.defaultProps = { theme: defaultTheme }; const CloseContainer = styled.div.withConfig({ displayName: "Modal__CloseContainer", componentId: "sc-15ie1vv-2" })(["", ""], ({ theme, scrolled, fixedClose, isMobileFullPage, modalWidth }) => css(["display:flex;", ";position:", ";top:", ";right:0;z-index:800;justify-content:flex-end;align-items:center;box-sizing:border-box;height:52px;width:100%;max-width:", ";box-shadow:", ";background-color:", ";border-top-left-radius:", ";border-top-right-radius:", ";transition:", ";pointer-events:none;", ";& + ", ":first-of-type{padding-top:52px;border-top:0;margin:0;}", "{pointer-events:auto;margin-", ":", ";& svg{transition:", ";color:", ";}&:hover svg{color:", ";}&:active svg{color:", ";}}"], fixedClose || scrolled ? css(["position:fixed;"]) : css(["position:absolute;"]), fixedClose || scrolled ? "fixed" : "absolute", !isMobileFullPage && (fixedClose || scrolled) ? "32px" : "0", modalWidth ? `${modalWidth}px` : getSizeToken, scrolled && theme.orbit.boxShadowFixed, scrolled && theme.orbit.paletteWhite, !isMobileFullPage && "12px", !isMobileFullPage && "12px", transition(["box-shadow", "background-color"], "fast", "ease-in-out"), media.largeMobile(css(["top:", ";right:", ";border-radius:0;"], (fixedClose || scrolled) && "0", (fixedClose || scrolled) && "auto")), StyledModalSection, StyledButtonPrimitive, right, theme.orbit.spaceXXSmall, transition(["color"], "fast", "ease-in-out"), theme.orbit.paletteInkLight, theme.orbit.paletteInkLightHover, theme.orbit.paletteInkLightActive)); // $FlowFixMe: https://github.com/flow-typed/flow-typed/issues/3653#issuecomment-568539198 CloseContainer.defaultProps = { theme: defaultTheme }; const ModalWrapperContent = styled.div.withConfig({ displayName: "Modal__ModalWrapperContent", componentId: "sc-15ie1vv-3" })(["", ""], ({ theme, isMobileFullPage, fixedFooter, footerHeight, fullyScrolled, scrolled, modalWidth, hasModalSection }) => css(["position:absolute;box-sizing:border-box;border-top-left-radius:", ";border-top-right-radius:", ";background-color:", ";font-family:", ";width:100%;", ";bottom:", ";box-shadow:", ";overflow-y:auto;overflow-x:hidden;", ";", "{top:", ";opacity:", ";visibility:", ";transition:", ";}", "{margin-bottom:", ";}", ";"], !isMobileFullPage && "12px", !isMobileFullPage && "12px", theme.orbit.backgroundModal, theme.orbit.fontFamily, isMobileFullPage ? css(["max-height:100%;top:0;"]) : css(["max-height:calc( 100% - ", " - ", " );"], theme.orbit.spaceXLarge, `${fixedFooter && Boolean(footerHeight) ? footerHeight : 0}px`), `${(!isMobileFullPage ? parseInt(theme.orbit.spaceXLarge, 10) : 0) + (fixedFooter && Boolean(footerHeight) ? footerHeight : 0)}px`, theme.orbit.boxShadowOverlay, fixedFooter && footerHeight && css(["", "{bottom:0;padding:", ";box-shadow:", ";position:fixed;transition:", ";}", ":last-of-type{padding-bottom:", ";margin-bottom:0;}"], StyledModalFooter, theme.orbit.spaceMedium, fullyScrolled ? `inset 0 1px 0 ${theme.orbit.paletteCloudNormal}, ${theme.orbit.boxShadowFixedReverse}` : `inset 0 0 0 transparent, ${theme.orbit.boxShadowFixedReverse}`, transition(["box-shadow"], "fast", "ease-in-out"), StyledModalSection, theme.orbit.spaceLarge), MobileHeader, !isMobileFullPage && scrolled && theme.orbit.spaceXLarge, scrolled && "1", scrolled && "visible", scrolled && css(["", ",", ""], transition(["top"], "normal", "ease-in-out"), transition(["opacity", "visibility"], "fast", "ease-in-out")), StyledModalHeader, !hasModalSection && theme.orbit.spaceXLarge, media.largeMobile(css(["position:relative;bottom:auto;border-radius:", ";padding-bottom:0;height:auto;overflow:visible;max-height:100%;", ":last-of-type{padding-bottom:", ";margin-bottom:", ";&::after{content:none;}}", "{margin-bottom:", ";}", "{padding:", ";max-width:", ";position:", ";box-shadow:", ";}", "{top:", ";width:", ";}"], !isMobileFullPage && "9px", StyledModalSection, theme.orbit.spaceXXLarge, fixedFooter ? `${footerHeight}px` : "0", StyledModalHeader, !hasModalSection && fixedFooter ? `${footerHeight}px` : "0", StyledModalFooter, fixedFooter ? `${theme.orbit.spaceXLarge} ${theme.orbit.spaceXLarge}!important` : theme.orbit.spaceXLarge, modalWidth ? `${modalWidth}px` : getSizeToken, fixedFooter && fullyScrolled && "absolute", fullyScrolled && "none", MobileHeader, scrolled ? "0" : `-${theme.orbit.spaceXXLarge}`, `calc(${modalWidth}px - 48px - ${theme.orbit.spaceXXLarge})`)))); // $FlowFixMe: https://github.com/flow-typed/flow-typed/issues/3653#issuecomment-568539198 ModalWrapperContent.defaultProps = { theme: defaultTheme }; const OFFSET = 40; const Modal = /*#__PURE__*/React.forwardRef(({ size = SIZES.NORMAL, scrollingElementRef, children, onClose, autoFocus = true, fixedFooter = false, isMobileFullPage = false, preventOverlayClose = false, hasCloseButton = true, mobileHeader = true, disableAnimation = false, dataTest, id, 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 { isLargeMobile } = useMediaQuery(); const scrollingElement = React.useRef(null); const setScrollingElementRefs = React.useCallback(node => { scrollingElement.current = node; if (scrollingElementRef) { if (typeof scrollingElementRef === "function") { scrollingElementRef(node); } else { // 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; // added in 4.0.3, interpolation of styled component return static className // $FlowFixMe const footerEl = content.querySelector(`${StyledModalFooter}`); const contentDimensions = content.getBoundingClientRect(); setModalWidth(contentDimensions.width); if (footerEl != null && 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 = () => { 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]); } }; 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 == null ? void 0 : lastFocusableEl.focus(); } else if (!event.shiftKey && document.activeElement === lastFocusableEl) { event.preventDefault(); firstFocusableEl == null ? void 0 : 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) && /ModalBody|ModalWrapper/.test(event.target.className); 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; // $FlowFixMe setScrolled(target.scrollTop >= 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; // $FlowFixMe const headingEl = content.querySelector(`${ModalHeading}`); 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) { setScrollStates(event.target, OFFSET, OFFSET, getScrollTopPoint()); } }; const handleMobileScroll = 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 = () => { setDimensions(); decideFixedFooter(); 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); return /*#__PURE__*/React.createElement(ModalBody, { tabIndex: "0", onKeyDown: handleKeyDown, onScroll: handleScroll, onClick: handleClickOutside, "data-test": dataTest, id: id, ref: modalBodyRef, role: "dialog", autoFocus: autoFocus, "aria-modal": "true", "aria-labelledby": hasModalTitle ? modalTitleID : null }, /*#__PURE__*/React.createElement(ModalWrapper, { size: size, loaded: loaded, onScroll: handleMobileScroll, fixedFooter: fixedFooter, isMobileFullPage: isMobileFullPage, disableAnimation: disableAnimation }, /*#__PURE__*/React.createElement(ModalWrapperContent, { size: size, fixedFooter: fixedFooter, scrolled: scrolled, ref: modalContentRef, fixedClose: fixedClose, fullyScrolled: fullyScrolled, modalWidth: modalWidth, footerHeight: footerHeight, hasModalSection: hasModalSection, isMobileFullPage: isMobileFullPage, onMouseDown: handleMouseDown }, hasCloseContainer && /*#__PURE__*/React.createElement(CloseContainer, { "data-test": "CloseContainer", modalWidth: modalWidth, size: size, scrolled: scrolled, fixedClose: fixedClose, isMobileFullPage: isMobileFullPage }, onClose && hasCloseButton && /*#__PURE__*/React.createElement(ModalCloseButton, { onClick: onClose, dataTest: CLOSE_BUTTON_DATA_TEST })), /*#__PURE__*/React.createElement(ModalContext.Provider, { value: { setHasModalTitle, setHasModalSection: () => setHasModalSection(true), removeHasModalSection: () => setHasModalSection(false), callContextFunctions, setFooterHeight, hasModalSection, hasMobileHeader: mobileHeader, isMobileFullPage, closable: Boolean(onClose), isInsideModal: true, titleID: modalTitleID } }, 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";