UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

223 lines (222 loc) 12.8 kB
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; } }