UNPKG

azure-devops-ui

Version:

React components for building web UI in Azure DevOps

502 lines (501 loc) 23.2 kB
import "../../CommonImports"; import "../../Core/core.css"; import "./Splitter.css"; import * as React from "react"; import { EventManagement } from '../../Core/EventManagement'; import { ObservableLike, ObservableValue } from '../../Core/Observable'; import { announce } from '../../Core/Util/Accessibility'; import { format } from '../../Core/Util/String'; import { Button } from '../../Button'; import { Observer } from '../../Observer'; import * as Resources from '../../Resources.Splitter'; import { css, getSafeId, KeyCode } from '../../Util'; import { FixedSizeLimitsFormat, SplitterDirection, SplitterElementPosition } from "../../Components/Splitter/Splitter.Props"; const DIVIDER_MOVE_INCREMENT = 20; const DIVIDER_WIDTH = 4; const COLLAPSED_PANE_SIZE = 38; let idCount = 0; export class Splitter extends React.Component { constructor(props, context) { super(props, context); // Cached children this._cachedNearElement = null; this._cachedFarElement = null; this.events = new EventManagement(); // Fixed size value used when in uncontrolled mode (when props.fixedSize is undefined) this.uncontrolledFixedSize = new ObservableValue(undefined); this.placeholderPosition = new ObservableValue(undefined); this.collapse = () => { if (!this.isCollapsed()) { this.props.onCollapsedChanged && this.props.onCollapsedChanged(true); announce(Resources.SplitterCollapsed); } }; this.expand = () => { if (this.isCollapsed()) { this.props.onCollapsedChanged && this.props.onCollapsedChanged(false); announce(Resources.SplitterExpanded); } }; /** * Keyboard handler for the divider */ this._onDividerKeyDown = (event) => { const { disabled, splitterDirection } = this.props; if (!disabled && !this._isDragging()) { switch (event.keyCode) { case KeyCode.leftArrow: if (splitterDirection === SplitterDirection.Vertical) { this._moveDivider(-DIVIDER_MOVE_INCREMENT); } break; case KeyCode.rightArrow: if (splitterDirection === SplitterDirection.Vertical) { this._moveDivider(DIVIDER_MOVE_INCREMENT); } break; case KeyCode.upArrow: if (splitterDirection === SplitterDirection.Horizontal) { this._moveDivider(-DIVIDER_MOVE_INCREMENT); } break; case KeyCode.downArrow: if (splitterDirection === SplitterDirection.Horizontal) { this._moveDivider(DIVIDER_MOVE_INCREMENT); } break; default: return; } event.preventDefault(); event.stopPropagation(); } }; /** * Fired when the user mouses down on the divider * If there is a fixed pane, records its initial size, and attaches mouse move and mouse up events to the window */ this._onDividerMouseDown = (event) => { this._onDividerDown(event, event.clientX, event.clientY); this._attachMouseWindowEvents(); }; /** * Fired when the user touches down on the divider * If there is a fixed pane, records its initial size, and attaches mouse move and mouse up events to the window */ this._onDividerTouchDown = (event) => { if (event.touches.length === 1) { this._onDividerDown(event, event.touches[0].clientX, event.touches[0].clientY); this._attachTouchWindowEvents(); } }; this._onDividerDown = (event, xPos, yPos) => { if (this._fixedRef && !this.props.disabled) { event.preventDefault(); event.stopPropagation(); this._dragAnchorPos = this._getEventBoundedClientPos(xPos, yPos); this._previousFixedSize = this._getElementSize(this._fixedRef); if (this.placeholderPosition.value !== undefined) { this.placeholderPosition.value = undefined; } this._handleDragEvent(event, xPos, yPos); } }; /** * Fired when the user moves their mouse, after having moused down on the divider * Computes the new location of the placeholder * @param event */ this._onDividerMouseMove = (event) => { this._handleDragEvent(event, event.clientX, event.clientY); }; /** * Fired when the user moves their mouse, after having moused down on the divider * Computes the new location of the placeholder * @param event */ this._onDividerTouchMove = (event) => { if (event.touches.length === 1) { this._handleDragEvent(event, event.touches[0].clientX, event.touches[0].clientY); } }; /** * Fired when the user releases their mouse, after having moused down on the divider * Updates the size of the fixed pane, and stops the drag * Removes window events */ this._onDividerMouseUp = (event) => { this._detachMouseWindowEvents(); this._onDividerEnd(event.clientX, event.clientY); }; /** * Fired when the user releases their touch, after having touched down on the divider * Updates the size of the fixed pane, and stops the drag * Removes window events */ this._onDividerTouchEnd = (event) => { this._detachTouchWindowEvents(); this._onDividerEnd(event.changedTouches[0].clientX, event.changedTouches[0].clientY); }; this._onDividerEnd = (xPos, yPos) => { const boundedClientPos = this._getEventBoundedClientPos(xPos, yPos); const newSize = this._getNewFixedSize(this._previousFixedSize, boundedClientPos - this._dragAnchorPos); this.placeholderPosition.value = undefined; this._setFixedSize(newSize); this._fireWindowResize(); }; this.refresh = () => { this.forceUpdate(); }; this.uncontrolledFixedSize.value = props.initialFixedSize; this.fixedPaneId = "splitter-fixed-pane" + idCount++; } get maxFixedSize() { return this._getSizeLimitValue(this.props.maxFixedSize); } get minFixedSize() { return this._getSizeLimitValue(this.props.minFixedSize); } componentDidMount() { this._fireWindowResize(); if (this.props.fixedSizeLimitsFormat === FixedSizeLimitsFormat.Percentage) { window.addEventListener("resize", this.refresh); // Force a refresh when the splitter is first rendered, so that the fixed size limits are computed correctly this.refresh(); } } componentWillUnmount() { this.events.removeAllListeners(); window.removeEventListener("resize", this.refresh); } render() { const { className, collapsed, fixedElement, onRenderFarElement, onRenderNearElement, splitterDirection } = this.props; const showDivider = fixedElement === SplitterElementPosition.Near ? !!onRenderNearElement : !!onRenderFarElement; return (React.createElement(Observer, { collapsed: collapsed, fixedSize: this.props.fixedSize === undefined ? this.uncontrolledFixedSize : this.props.fixedSize, placeholderPosition: this.placeholderPosition }, (props) => { let fixedSize; const maxFixedSize = this.maxFixedSize; const minFixedSize = this.minFixedSize; if (props.collapsed) { fixedSize = COLLAPSED_PANE_SIZE; } else if (props.fixedSize) { fixedSize = Math.max(props.fixedSize, minFixedSize || 0); let max = maxFixedSize; if (!max && this._splitterContainer) { max = this._getElementSize(this._splitterContainer); } if (max && fixedSize > max) { fixedSize = max; } } else { if (minFixedSize !== undefined) { fixedSize = minFixedSize; } else if (maxFixedSize !== undefined) { fixedSize = maxFixedSize; } } return (React.createElement("div", { className: css(className, "vss-Splitter--container", splitterDirection === SplitterDirection.Vertical && "vss-Splitter--container-row", splitterDirection === SplitterDirection.Horizontal && "vss-Splitter--container-column", this._isDragging() && "vss-Splitter--container-dragging"), ref: (splitterContainer) => (this._splitterContainer = splitterContainer) }, this._renderNearElement(fixedSize), showDivider ? this._renderDivider(fixedSize) : null, this._renderDragPlaceHolder(), this._renderFarElement(fixedSize))); })); } /** * Renders the first child */ _renderNearElement(fixedSize) { const { fixedElement, onRenderNearElement, nearElementClassName } = this.props; if (!this._isDragging() || !this._cachedNearElement) { if (onRenderNearElement) { const content = onRenderNearElement(); this._cachedNearElement = fixedElement === SplitterElementPosition.Near ? this._renderFixedPane(content, nearElementClassName, fixedSize) : this._renderFlexiblePane(content, nearElementClassName); } else { this._cachedNearElement = null; } } return this._cachedNearElement; } /** * Renders the last child. If there are 0-1 children, will render a flexible pane */ _renderFarElement(fixedSize) { const { fixedElement, onRenderFarElement, farElementClassName } = this.props; if (!this._isDragging() || !this._cachedFarElement) { if (onRenderFarElement) { const content = onRenderFarElement(); this._cachedFarElement = fixedElement === SplitterElementPosition.Far ? this._renderFixedPane(content, farElementClassName, fixedSize) : this._renderFlexiblePane(content, farElementClassName); } else { this._cachedFarElement = null; } } return this._cachedFarElement; } /** * Render the fixed pane, with size determined by state */ _renderFixedPane(content, className, fixedSize) { const { expandTooltip, splitterDirection } = this.props; const collapsed = this.isCollapsed(); const styleName = splitterDirection === SplitterDirection.Vertical ? "width" : "height"; const dividerStyle = { [styleName]: fixedSize === undefined ? "50%" : fixedSize - DIVIDER_WIDTH - 1 // small adjustment to account for the divider }; return content ? (React.createElement("div", { className: css("vss-Splitter--pane-fixed", collapsed ? "flex-column collapsed" : className), id: getSafeId(this.fixedPaneId), style: dividerStyle, ref: (fixedRef) => (this._fixedRef = fixedRef) }, collapsed ? (React.createElement(Button, { className: "vss-splitter-expand-button", iconProps: { iconName: this.getCollapsedButtonIconName() }, onClick: this.expand, subtle: true, tooltipProps: { text: expandTooltip || Resources.ExpandTooltip } })) : (content))) : null; } getCollapsedButtonIconName() { const farSideFixed = this.props.fixedElement === SplitterElementPosition.Far; if (this.props.splitterDirection === SplitterDirection.Vertical) { return farSideFixed ? "DoubleChevronLeft" : "DoubleChevronRight"; } else { return farSideFixed ? "DoubleChevronUp" : "DoubleChevronDown"; } } /** * Render the flexible pane */ _renderFlexiblePane(content, className) { return React.createElement("div", { className: css("vss-Splitter--pane-flexible", className) }, content); } /** * Render the divider */ _renderDivider(fixedSize) { const { startBound, endBound } = this._getSplitterBoundaries(); let dividerPosition = 0; if (this._fixedRef) { dividerPosition = this._getElementSize(this._fixedRef); } return (React.createElement("div", { "aria-valuemin": startBound, "aria-valuemax": endBound - DIVIDER_WIDTH - 1, "aria-label": this.props.ariaLabel, "aria-labelledby": this.props.ariaLabel ? undefined : this.props.ariaLabelledBy ? this.props.ariaLabelledBy : getSafeId(this.fixedPaneId), "aria-orientation": this.props.splitterDirection === SplitterDirection.Horizontal ? "horizontal" : "vertical", "aria-valuenow": fixedSize ? fixedSize - DIVIDER_WIDTH - 1 : dividerPosition, "aria-valuetext": format(Resources.SplitterValueText, fixedSize ? fixedSize - DIVIDER_WIDTH - 1 : dividerPosition), role: "separator", tabIndex: 0, className: css("vss-Splitter--divider", this._isDragging() && "vss-Splitter--divider-dragging"), onKeyDown: this._onDividerKeyDown, onMouseDown: this._onDividerMouseDown, onTouchStart: this._onDividerTouchDown })); } /** * Render the placeholder if the user is dragging */ _renderDragPlaceHolder() { if (this._isDragging()) { const styleName = this.props.splitterDirection === SplitterDirection.Vertical ? "left" : "top"; const placeholderStyle = { [styleName]: this.placeholderPosition.value }; return React.createElement("div", { className: "vss-Splitter--drag-placeholder", style: placeholderStyle }); } else { return null; } } /** * Computes the new location of the placeholder based on the mouse event. * @param event */ _handleDragEvent(event, xPos, yPos) { const { fixedElement } = this.props; event.preventDefault(); event.stopPropagation(); const boundedClientPos = this._getEventBoundedClientPos(xPos, yPos); const newFixedSize = this._getNewFixedSize(this._previousFixedSize, boundedClientPos - this._dragAnchorPos); const newSize = newFixedSize.collapsed ? 0 : newFixedSize.fixedSize; const newPlaceholderValue = fixedElement === SplitterElementPosition.Near ? newSize : this._getElementSize(this._splitterContainer) - newSize - DIVIDER_WIDTH; if (newPlaceholderValue !== this.placeholderPosition.value) { this.placeholderPosition.value = newPlaceholderValue; } } _setFixedSize(newFixedSize) { const { onFixedSizeChanged } = this.props; if (newFixedSize.collapsed) { this.collapse(); return; } const fixedSize = newFixedSize.fixedSize; this.uncontrolledFixedSize.value = fixedSize; if (onFixedSizeChanged) { onFixedSizeChanged(fixedSize); } if (this.isCollapsed()) { this.expand(); } } /** * Move the divider in a near or far direction * @param direction The Direction */ _moveDivider(delta) { const currentSize = this._getElementSize(this._fixedRef); const newSize = this._getNewFixedSize(currentSize, delta); this._setFixedSize(newSize); this._fireWindowResize(); } /** Attaches mouse events to the window */ _attachMouseWindowEvents() { this.events.addEventListener(window, "mousemove", this._onDividerMouseMove); this.events.addEventListener(window, "mouseup", this._onDividerMouseUp); } /** Detaches mouse events to the window */ _detachMouseWindowEvents() { this.events.removeEventListener(window, "mousemove", this._onDividerMouseMove); this.events.removeEventListener(window, "mouseup", this._onDividerMouseUp); } /** Attaches touch events to the window */ _attachTouchWindowEvents() { this.events.addEventListener(window, "touchmove", this._onDividerTouchMove); this.events.addEventListener(window, "touchend", this._onDividerTouchEnd); } /** Detaches touch events to the window */ _detachTouchWindowEvents() { this.events.removeEventListener(window, "touchmove", this._onDividerTouchMove); this.events.removeEventListener(window, "touchend", this._onDividerTouchEnd); } /** * Get a X/Y position of a mouse event, relative to the splitter container and depending on the splitter direction * The position will be bounded within the splitter container and the min/max widths of the fixed panel * @param event */ _getEventBoundedClientPos(xPos, yPos) { const { splitterDirection } = this.props; let clientPos; switch (splitterDirection) { case SplitterDirection.Vertical: clientPos = xPos; break; case SplitterDirection.Horizontal: clientPos = yPos; break; default: clientPos = 0; } return this._getBoundedClientPos(clientPos); } /** * Given a position relative to the window, get a position relative to the splitter container and depending on the splitter direction * The position will be bounded within the splitter container and the min/max widths of the fixed panel * @param clientPos The position relative to the window * @param props The props to use */ _getBoundedClientPos(clientPos) { const { startBound, endBound } = this._getSplitterBoundaries(!!this.props.onCollapsedChanged); const boundedClientPos = Math.max( // Smallest allowed client pos start startBound, // Largest allowed client pos end Math.min(clientPos, endBound)); // Adjust relative to the container return boundedClientPos - this._getElementStartPos(this._splitterContainer); } /** * Compute the allowable pixel value bounds for the splitter * @param props The props to use */ _getSplitterBoundaries(ignoreMinFixedSize = false) { let fixedElement = this.props.fixedElement; let minFixedSize = this.minFixedSize; const maxFixedSize = this.maxFixedSize; if (!this._splitterContainer) { return { startBound: 0, endBound: 0 }; } if (ignoreMinFixedSize) { minFixedSize = 0; } const startPos = this._getElementStartPos(this._splitterContainer); const size = this._getElementSize(this._splitterContainer); const endPos = startPos + size; const startBound = fixedElement === SplitterElementPosition.Near ? minFixedSize ? startPos + minFixedSize : startPos : maxFixedSize ? endPos - maxFixedSize : startPos; const endBound = fixedElement === SplitterElementPosition.Near ? maxFixedSize ? startPos + maxFixedSize : endPos : minFixedSize ? endPos - minFixedSize : endPos; return { startBound, endBound }; } /** * Gets a new width from the initial size, a delta, and splitter props * * @param initialSize The initial width * @param delta The new position minus the drag anchor * @param props The splitter props to use */ _getNewFixedSize(initialSize, delta) { let { fixedElement, onCollapsedChanged } = this.props; let maxFixedSize = this.maxFixedSize; const minFixedSize = this.minFixedSize || 0; if (maxFixedSize === undefined) { maxFixedSize = this._getElementSize(this._splitterContainer); } let posDiff = delta; if (fixedElement === SplitterElementPosition.Far) { posDiff *= -1; } let fixedSize = initialSize + posDiff; if (fixedSize > maxFixedSize) { fixedSize = maxFixedSize; } let collapsed = this.isCollapsed(); if (onCollapsedChanged) { collapsed = fixedSize < COLLAPSED_PANE_SIZE || (!this.isCollapsed() && fixedSize < minFixedSize); } if (fixedSize < minFixedSize) { fixedSize = minFixedSize; } return { fixedSize, collapsed }; } isCollapsed() { return !!ObservableLike.getValue(this.props.collapsed); } /** * Indicates if a drag operation is in process */ _isDragging() { return this.placeholderPosition.value !== undefined; } /** * Get the size (width or height) of an element, based on the splitter direction * @param element The element */ _getElementSize(element) { return this.props.splitterDirection === SplitterDirection.Vertical ? element.clientWidth : element.clientHeight; } /** * Get the start position (left or top) of an element, based on the splitter direction * @param element The element */ _getElementStartPos(element) { const boundingRect = element.getBoundingClientRect(); return this.props.splitterDirection === SplitterDirection.Vertical ? boundingRect.left : boundingRect.top; } _fireWindowResize() { const event = document.createEvent("Event"); event.initEvent("resize", false, true); window.dispatchEvent(event); } _getSizeLimitValue(sizeLimitPercent) { if (this.props.fixedSizeLimitsFormat !== FixedSizeLimitsFormat.Percentage) { return sizeLimitPercent; } if (!sizeLimitPercent || !this._splitterContainer) { return undefined; } return sizeLimitPercent * this._getElementSize(this._splitterContainer); } } Splitter.defaultProps = { fixedElement: SplitterElementPosition.Far, splitterDirection: SplitterDirection.Vertical, fixedSizeLimitsFormat: FixedSizeLimitsFormat.Pixels };