UNPKG

@patreon/studio

Version:

Patreon Studio Design System

172 lines (171 loc) 6.89 kB
'use client'; import { arrow, autoUpdate, flip, limitShift, offset, shift, useFloating } from '@floating-ui/react-dom'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import styled from 'styled-components'; import { useSequentialId } from '../../hooks/useSequentialId'; import { tokens } from '../../tokens'; import { hasResizeObserver } from '../../utilities/feature-detection'; import { convertLegacyUnitValue } from '../../utilities/legacy-units'; import { useOverlayStack } from '../OverlayStackProvider'; import { PortalPassthrough } from '../PortalPassthrough'; function noop() { // Noop } const StyledPopover = styled.div ` isolation: isolate; z-index: ${tokens.global.layer.z12}; border-radius: ${tokens.global.radius.md}; background-color: ${tokens.global.bg.elevated.default}; box-shadow: ${tokens.global.boxShadow.mid}; max-width: ${({ maxWidth }) => (maxWidth ? convertLegacyUnitValue(maxWidth) : '248px')}; min-width: ${({ minWidth }) => (minWidth ? convertLegacyUnitValue(minWidth) : '160px')}; `; const StyledContent = styled.div ` padding: ${tokens.global.space.x16}; border-radius: ${tokens.global.radius.md}; background-color: ${tokens.global.bg.elevated.default}; `; function getArrowOffset(placement) { switch (placement) { case 'left': case 'left-end': case 'left-start': return 'right'; case 'right': case 'right-end': case 'right-start': return 'left'; case 'bottom': case 'bottom-end': case 'bottom-start': return 'top'; case 'top': case 'top-end': case 'top-start': default: return 'bottom'; } } const StyledArrow = styled.div ` position: absolute; width: ${tokens.global.space.x48}; height: ${tokens.global.space.x48}; pointer-events: none; ${({ arrowX }) => arrowX !== undefined && `left: ${arrowX}px;`} ${({ arrowY }) => arrowY !== undefined && `top: ${arrowY}px;`} ${({ placement }) => `${getArrowOffset(placement)}: -16px;`} &::after { display: block; content: ''; position: absolute; z-index: -1; border-radius: 1px; box-shadow: ${tokens.global.boxShadow.mid}; background-color: ${tokens.global.bg.elevated.default}; width: 27px; height: 27px; top: 50%; left: 50%; transform: translate(-50%, -50%) rotate(45deg); } `; export function Popover({ children, content, 'data-tag': dataTag, hideArrow = false, id: specifiedId, isOpen = false, maxWidth, minWidth, onCloseRequest = noop, preferredPlacement = 'top', renderMode = 'adjacent', autoPositioning = 'default', }) { const popoverId = useSequentialId('Popover'); const id = specifiedId ?? popoverId ?? 'popover'; useOverlayStack(id, isOpen); const [isMounted, setIsMounted] = useState(false); const arrowRef = useRef(null); const handleKeyDown = useCallback((e) => { if (e.key === 'Esc' || e.key === 'Escape') { onCloseRequest(); } }, [onCloseRequest]); const middleware = [ // Offset the popover by 16px offset(hideArrow ? 8 : 16), // If the popover is overflowing the viewport, allow for // shifting its position to ensure it remains visible shift({ limiter: limitShift(), padding: 12 }), arrow({ element: arrowRef }), ]; // If `autoPositioning` is disabled we render `Popover` in the prefered placement location regardless of screen position. if (autoPositioning === 'default') { // By default the popover will be placed on the side given // via `preferredPlacement`. When there isn't enough space // on that side, we "flip" it to the opposite. middleware.splice(1, 0, flip()); } const { floatingStyles, placement, refs, middlewareData: { arrow: { x: arrowX, y: arrowY } = {} }, } = useFloating({ // Where we want to place the popover placement: preferredPlacement, // Some middleware for positioning calculations middleware, // So long as the popover is visible, re-calc positioning on // scroll and resize whileElementsMounted: hasResizeObserver ? autoUpdate : undefined, }); // Track when component has rendered useEffect(() => { setIsMounted(true); }, []); // Handle outside clicks useEffect(() => { // Don't attach a handler if the popover is closed or if the // user has not provided an `onCloseRequest` callback. if (!isOpen || onCloseRequest === noop) { return noop; } const container = refs.floating.current; // We manually wire up the `reference` callback to the wrapped element. const trigger = refs.reference.current; function handleClick(e) { if (container === null) { return; } const target = e.target; if (!container.contains(target) && !trigger.contains(target)) { onCloseRequest(); } } window.addEventListener('click', handleClick, true); return () => { window.removeEventListener('click', handleClick, true); }; }, [isOpen, onCloseRequest, refs.floating, refs.reference, renderMode]); const popover = ( // Putting a keydown handler here just to rely on this as a boundary // eslint-disable-next-line jsx-a11y/no-static-element-interactions <div onKeyDown={handleKeyDown}> <PortalPassthrough> <StyledPopover ref={refs.setFloating} role="dialog" id={id} data-tag={dataTag} style={floatingStyles} maxWidth={maxWidth} minWidth={minWidth}> <StyledContent>{content}</StyledContent> {!hideArrow && <StyledArrow ref={arrowRef} placement={placement} arrowX={arrowX} arrowY={arrowY}/>} </StyledPopover> </PortalPassthrough> </div>); // Handle legacy children API if (typeof children === 'function') { return (<> {children({ ref: refs.setReference, ariaProps: { 'aria-controls': isOpen ? id : undefined, 'aria-expanded': isOpen, }, })} {isOpen && renderMode === 'adjacent' && popover} {isOpen && renderMode === 'portal' && isMounted && createPortal(popover, document.body)} </>); } return (<> {React.cloneElement(React.Children.only(children), { ref: refs.setReference, 'aria-controls': isOpen ? id : undefined, 'aria-expanded': isOpen, })} {isOpen && renderMode === 'adjacent' && popover} {isOpen && renderMode === 'portal' && isMounted && createPortal(popover, document.body)} </>); } //# sourceMappingURL=index.jsx.map