azure-devops-ui
Version:
React components for building web UI in Azure DevOps
502 lines (501 loc) • 23.2 kB
JavaScript
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
};