azure-devops-ui
Version:
React components for building web UI in Azure DevOps
223 lines (222 loc) • 12.8 kB
JavaScript
import "../../CommonImports";
import "../../Core/core.css";
import * as React from "react";
import { shimRef } from '../../Util';
import { ResponsiveOrientation } from "./ResponsiveLayout.Props";
/**
* The ResponsiveLayout component is used to create a container that responds to
* its size. Children of the layout container element will be shown or hidden
* based on the amount of space available. The client creates the ResponsiveLayout
* around the element that should be managed.
*
* The children of the layout container MUST map one element per child. This allows
* the ResponsiveLayout to map visibility of the component to its relative DOM
* element. The child MAY be a component and is not required to be a direct DOM
* element. The child component MUST result in one root DOM element.
*/
export class ResponsiveLayout extends React.Component {
constructor() {
super(...arguments);
/**
* Details about each of the children in the responsive layout.
*/
this.childDetails = [];
/**
* All components within the responsiveLayout MUST specific a unique key. The
* key should follow the same rules as a standard React key. If the component
* fundamentally changes the key should change along with it.
*/
this.childKeys = [];
/**
* ref to the container element used by the responsive layout. The direct children
* are the elements that are responsive.
*/
this.containerRef = React.createRef();
/**
* Number of hidden components in the layout.
*/
this.hiddenCount = 0;
/**
* Timeout used to notify callers about changes to the visible elements.
*/
this.layoutTimeout = 0;
this.updateLayout = () => {
const componentElement = this.containerRef.current;
if (componentElement && componentElement.children.length) {
const hiddenCount = this.hiddenCount;
const componentClientRect = componentElement.getBoundingClientRect();
let renderedSize = 0;
let ignoredSize = 0;
let initialPass = false;
// If we dont have the child details computed, or the children have changed we need
// to populate the child details.s
// @NOTE: We need to be able to detect changes to children without the length changing.
if (!this.childDetails || componentElement.children.length !== this.childDetails.length) {
this.childDetails = [];
initialPass = true;
}
// We need to go through and compute the sizes of the child components.
for (let componentIndex = 0; componentIndex < componentElement.children.length; componentIndex++) {
const clientRect = componentElement.children[componentIndex].getBoundingClientRect();
// If this is the initial pass we will create new adjust entries for this components
// otherwise we will just update the current state.
if (initialPass) {
const childDetails = {
appliedSize: 0,
clientRect: clientRect
};
// Get margins of the current child element
const element = window.getComputedStyle(componentElement.children[componentIndex]);
if (element) {
childDetails.margins = {
left: parseInt(element.getPropertyValue("margin-left") || "0"),
right: parseInt(element.getPropertyValue("margin-right") || "0")
};
}
this.childDetails.push(childDetails);
}
else {
this.childDetails[componentIndex].clientRect = clientRect;
}
// Track ignored component sizes independently, this will help with rounding issues.
if (this.props.ignoredChildren && this.props.ignoredChildren.indexOf(componentIndex) >= 0) {
ignoredSize += this.props.orientation === ResponsiveOrientation.Vertical ? clientRect.height : clientRect.width;
}
}
// The renderedSize is equal to the size from the end of the last component to the
// start of the first component minus any ignored space.
if (this.props.orientation === ResponsiveOrientation.Vertical) {
renderedSize = Math.floor(this.childDetails[this.childDetails.length - 1].clientRect.bottom - this.childDetails[0].clientRect.top);
}
else {
renderedSize = Math.floor(this.childDetails[this.childDetails.length - 1].clientRect.right - this.childDetails[0].clientRect.left);
}
renderedSize -= Math.floor(ignoredSize);
// If there is not enough space we will try to adjust items smaller first.
const componentClientSize = Math.floor(this.props.orientation === ResponsiveOrientation.Vertical ? componentClientRect.height : componentClientRect.width);
if (componentClientSize <= renderedSize) {
let availableSpace = componentClientSize - renderedSize;
while (availableSpace < 0 && this.hiddenCount < this.props.responsiveChildren.length) {
const childIndex = this.props.responsiveChildren[this.hiddenCount];
const childDetail = this.childDetails[childIndex];
// Determine how much space we will recoupe from this component.
let appliedSize = Math.ceil(this.props.orientation === ResponsiveOrientation.Vertical ? childDetail.clientRect.height : childDetail.clientRect.width);
// Apply buffer size to prevent flickering.
// The buffer must be at least a certain size - considering element's margins.
// -------------- -------------- --------------
// <-- | | --> <-------- | | --> <-------- | | -->
// -------------- -------------- --------------
// <<<<<<<<<<^^^^^^^^^^^^^^>>>>
// margin element margin
// Side margins are calculated dynamically
// But in case of need they could be found in related css classes.
// (for example, HeaderCommandBar - ".rhythm-horizontal-8 > :not(:first-child)")
const margins = childDetail.margins ? childDetail.margins.left + childDetail.margins.right : 0;
const buffer = margins || 8;
appliedSize += buffer;
// Apply the next adjustment to the child components.
availableSpace += appliedSize;
// Mark the child hidden and track how space we recouped from component.
childDetail.hidden = true;
childDetail.appliedSize = appliedSize;
// Move on to the next component if we need more space.
this.hiddenCount++;
// console.log("Adjust (shrink), applied " + appliedSize + " from child " + childIndex);
}
}
// If we have availableSpace and there are adjusted items we should see if we can give
// some back to items.
else if (componentClientSize > renderedSize) {
let availableSpace = componentClientSize - renderedSize;
while (this.hiddenCount > 0) {
const childIndex = this.props.responsiveChildren[this.hiddenCount - 1];
const childDetail = this.childDetails[childIndex];
// Check if there is enough space for this component. "appliedSize" already contains buffer
if (childDetail.appliedSize >= availableSpace) {
break;
}
// Apply the next adjustment to the child components.
availableSpace -= childDetail.appliedSize;
childDetail.hidden = false;
// Now that this component is visible we will decrement its count.
this.hiddenCount--;
// console.log("Adjust (grow), applied " + childDetail.appliedSize + " from child " + childIndex);
}
}
// If adjustments were applied we need to notify the owner on the change and re-layout.
if (hiddenCount != this.hiddenCount) {
this.layoutTimeout = window.setTimeout(() => {
this.layoutTimeout = 0;
if (this.props.onLayoutChange) {
this.props.onLayoutChange(this.hiddenCount);
}
// Force updates to the components and we will see if we have gained enough space.
this.forceUpdate();
}, 0);
}
}
};
}
render() {
const childKeys = [];
const container = React.Children.only(this.props.children);
// Get the to the container for us to use during sizing calculations.
this.containerRef = shimRef(container);
// Clone the container and insert the placeholders for hidden children.
const children = React.cloneElement(container, Object.assign(Object.assign({}, container.props), { ref: this.containerRef }), React.Children.map(container.props.children, (child, index) => {
if (false) {
if (typeof child === "function") {
throw Error("Functional components aren't allowed as the direct child of a ResponsiveLayout container");
}
if (typeof child === "string" || typeof child === "number" || typeof child === "boolean") {
throw Error("Raw values aren't allowed as the direct child of a ResponsiveLayout container, wrap it in a span and ensure it has a key.");
}
}
// ALL children MUST have unique keys.
if (!child.key) {
console.warn("All children MUST have a unique key");
child.key = index;
}
childKeys[index] = child.key;
// If the component has been hidden by an layout, we will render a placeholder, that takes 0 size.
if (this.childDetails && this.childDetails[index] && this.childDetails[index].hidden) {
return React.createElement("div", { key: "PH" + index, className: "responsive-placeholder" });
}
return child;
}));
// If the children have changed we will reset the layout and start over.
if (this.childKeys) {
if (this.childKeys.length !== childKeys.length) {
this.resetLayout();
}
else {
for (let keyIndex = 0; keyIndex < childKeys.length; keyIndex++) {
if (this.childKeys[keyIndex] !== childKeys[keyIndex]) {
this.resetLayout();
break;
}
}
}
}
this.childKeys = childKeys;
return children;
}
componentDidMount() {
window.addEventListener("resize", this.updateLayout);
this.updateLayout();
}
componentDidUpdate() {
this.updateLayout();
}
componentWillUnmount() {
if (this.layoutTimeout) {
window.clearTimeout(this.layoutTimeout);
this.layoutTimeout = 0;
}
window.removeEventListener("resize", this.updateLayout);
}
resetLayout() {
this.childDetails = undefined;
this.hiddenCount = 0;
}
}