@patreon/studio
Version:
Patreon Studio Design System
172 lines (171 loc) • 6.89 kB
JSX
'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