@shopify/polaris
Version:
Shopify’s product component library
175 lines (174 loc) • 7.73 kB
JavaScript
import React from 'react';
import { getRectForNode, Rect } from '@shopify/javascript-utilities/geometry';
import { closest } from '@shopify/javascript-utilities/dom';
import { classNames } from '../../utilities/css';
import { EventListener } from '../EventListener';
import { Scrollable } from '../Scrollable';
import { layer } from '../shared';
import { calculateVerticalPosition, calculateHorizontalPosition, rectIsOutsideOfRect, } from './utilities/math';
import styles from './PositionedOverlay.scss';
const OBSERVER_CONFIG = { childList: true, subtree: true };
export class PositionedOverlay extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
measuring: true,
activatorRect: getRectForNode(this.props.activator),
left: 0,
top: 0,
height: 0,
width: null,
positioning: 'below',
zIndex: null,
outsideScrollableContainer: false,
lockPosition: false,
};
this.overlay = null;
this.scrollableContainer = null;
this.overlayDetails = () => {
const { measuring, left, positioning, height, activatorRect } = this.state;
return {
measuring,
left,
desiredHeight: height,
positioning,
activatorRect,
};
};
this.setOverlay = (node) => {
this.overlay = node;
};
this.handleMeasurement = () => {
const { lockPosition, top } = this.state;
this.observer.disconnect();
this.setState(({ left, top }) => ({
left,
top,
height: 0,
positioning: 'below',
measuring: true,
}), () => {
if (this.overlay == null || this.scrollableContainer == null) {
return;
}
const { activator, preferredPosition = 'below', preferredAlignment = 'center', onScrollOut, fullWidth, fixed, } = this.props;
const textFieldActivator = activator.querySelector('input');
const activatorRect = textFieldActivator != null
? getRectForNode(textFieldActivator)
: getRectForNode(activator);
const currentOverlayRect = getRectForNode(this.overlay);
const scrollableElement = isDocument(this.scrollableContainer)
? document.body
: this.scrollableContainer;
const scrollableContainerRect = getRectForNode(scrollableElement);
const overlayRect = fullWidth
? Object.assign({}, currentOverlayRect, { width: activatorRect.width }) : currentOverlayRect;
// If `body` is 100% height, it still acts as though it were not constrained to that size. This adjusts for that.
if (scrollableElement === document.body) {
scrollableContainerRect.height = document.body.scrollHeight;
}
const overlayMargins = this.overlay.firstElementChild
? getMarginsForNode(this.overlay.firstElementChild)
: { activator: 0, container: 0, horizontal: 0 };
const containerRect = windowRect();
const zIndexForLayer = getZIndexForLayerFromNode(activator);
const zIndex = zIndexForLayer == null ? zIndexForLayer : zIndexForLayer + 1;
const verticalPosition = calculateVerticalPosition(activatorRect, overlayRect, overlayMargins, scrollableContainerRect, containerRect, preferredPosition, fixed);
const horizontalPosition = calculateHorizontalPosition(activatorRect, overlayRect, containerRect, overlayMargins, preferredAlignment);
this.setState({
measuring: false,
activatorRect: getRectForNode(activator),
left: horizontalPosition,
top: lockPosition ? top : verticalPosition.top,
lockPosition: Boolean(fixed),
height: verticalPosition.height || 0,
width: fullWidth ? overlayRect.width : null,
positioning: verticalPosition.positioning,
outsideScrollableContainer: onScrollOut != null &&
rectIsOutsideOfRect(activatorRect, intersectionWithViewport(scrollableContainerRect)),
zIndex,
}, () => {
if (!this.overlay)
return;
this.observer.observe(this.overlay, OBSERVER_CONFIG);
});
});
};
this.observer = new MutationObserver(this.handleMeasurement);
}
componentDidMount() {
this.scrollableContainer = Scrollable.forNode(this.props.activator);
if (this.scrollableContainer && !this.props.fixed) {
this.scrollableContainer.addEventListener('scroll', this.handleMeasurement);
}
this.handleMeasurement();
}
componentWillUnmount() {
if (this.scrollableContainer && !this.props.fixed) {
this.scrollableContainer.removeEventListener('scroll', this.handleMeasurement);
}
}
componentDidUpdate() {
const { outsideScrollableContainer, top } = this.state;
const { onScrollOut, active } = this.props;
if (active &&
onScrollOut != null &&
top !== 0 &&
outsideScrollableContainer) {
onScrollOut();
}
}
render() {
const { left, top, zIndex, width } = this.state;
const { render, fixed, classNames: propClassNames } = this.props;
const style = {
top: top == null || isNaN(top) ? undefined : top,
left: left == null || isNaN(left) ? undefined : left,
width: width == null || isNaN(width) ? undefined : width,
zIndex: zIndex == null || isNaN(zIndex) ? undefined : zIndex,
};
const className = classNames(styles.PositionedOverlay, fixed && styles.fixed, propClassNames);
return (<div className={className} style={style} ref={this.setOverlay}>
<EventListener event="resize" handler={this.handleMeasurement}/>
{render(this.overlayDetails())}
</div>);
}
}
export function intersectionWithViewport(rect, viewport = windowRect()) {
const top = Math.max(rect.top, 0);
const left = Math.max(rect.left, 0);
const bottom = Math.min(rect.top + rect.height, viewport.height);
const right = Math.min(rect.left + rect.width, viewport.width);
return new Rect({
top,
left,
height: bottom - top,
width: right - left,
});
}
function getMarginsForNode(node) {
const nodeStyles = window.getComputedStyle(node);
return {
activator: parseFloat(nodeStyles.marginTop || ''),
container: parseFloat(nodeStyles.marginBottom || ''),
horizontal: parseFloat(nodeStyles.marginLeft || ''),
};
}
function getZIndexForLayerFromNode(node) {
const layerNode = closest(node, layer.selector) || document.body;
const zIndex = layerNode === document.body
? 'auto'
: parseInt(window.getComputedStyle(layerNode).zIndex || '0', 10);
return zIndex === 'auto' || isNaN(zIndex) ? null : zIndex;
}
function windowRect() {
return new Rect({
top: window.scrollY,
left: window.scrollX,
height: window.innerHeight,
width: window.innerWidth,
});
}
function isDocument(node) {
return node === document;
}