azure-devops-ui
Version:
React components for building web UI in Azure DevOps
206 lines (205 loc) • 12.3 kB
JavaScript
import "../../CommonImports";
import "../../Core/core.css";
import "./Callout.css";
import * as React from "react";
import { FocusWithin } from '../../FocusWithin';
import { FocusZone } from '../../FocusZone';
import { Portal } from '../../Portal';
import { css, getSafeId, KeyCode } from '../../Util';
import { Location, position, updateLayout } from '../../Utilities/Position';
import { TimerManagement } from '../../Core/TimerManagement';
import { SurfaceBackground, SurfaceContext } from '../../Surface';
import { ContentJustification, ContentLocation, ContentOrientation, ContentSize } from "./Callout.Props";
export class Callout extends React.Component {
constructor() {
super(...arguments);
this.calloutContent = React.createRef();
}
render() {
const { portalProps } = this.props;
return (React.createElement(Portal, Object.assign({}, portalProps, { className: css(portalProps && portalProps.className, this.props.anchorElement && "bolt-layout-relative") }),
React.createElement(CalloutContent, Object.assign({ ref: this.calloutContent }, this.props))));
}
componentWillUnmount() {
// We need to let the content handle the WillUnmount before the Portal, this
// will ensure the the callout can deal with unmounting content that still has
// focus. Otherwise the root will be detached from the document and focus will
// have moved to the body.
if (this.calloutContent.current) {
this.calloutContent.current.portalWillUnmount();
}
}
updateLayout() {
if (this.calloutContent.current) {
this.calloutContent.current.updateLayout();
}
}
}
Callout.defaultProps = {
blurDismiss: false,
viewportChangeDismiss: true
};
class CalloutContent extends React.Component {
constructor(props) {
super(props);
this.calloutElement = React.createRef();
this.relayoutTimer = new TimerManagement();
this.scrollListen = false;
this.scrollEvent = null;
this.initialScreenWidth = window.innerWidth;
this.onBlur = () => {
this.props.onDismiss && this.props.onDismiss();
};
this.onClick = (event) => {
// If we click on the light dismiss div we will dismiss it.
if (this.props.lightDismiss && !event.defaultPrevented) {
if (this.props.onDismiss) {
this.props.onDismiss();
}
event.preventDefault();
}
};
this.onKeyDown = (event) => {
var _a, _b;
// If we press escape from within the callout this will dismiss it.
if (this.props.escDismiss && event.which === KeyCode.escape && !event.defaultPrevented) {
if (this.props.onDismiss) {
this.props.onDismiss();
}
event.preventDefault();
}
(_b = (_a = this.props).onKeyDown) === null || _b === void 0 ? void 0 : _b.call(_a, event);
};
this.onResize = () => {
// Fix for issue where the soft keyboard on android closes callouts.
if (this.props.viewportChangeDismiss === true &&
(this.initialScreenWidth !== window.innerWidth ||
!document.activeElement ||
(document.activeElement.tagName !== "INPUT" && document.activeElement.tagName !== "TEXTAREA"))) {
this.props.onDismiss && this.props.onDismiss();
}
else if (this.props.updateLayout) {
this.relayoutTimer.clearAllTimers();
this.relayoutTimer.setTimeout(() => {
this.updateLayout();
}, 200);
}
};
this.onScroll = (event) => {
if (this.scrollListen) {
this.scrollEvent = event.nativeEvent;
}
};
this.onScrollDocument = (event) => {
if (this.scrollListen) {
if (event === this.scrollEvent) {
this.scrollEvent = null;
}
else {
if (this.props.viewportChangeDismiss === true) {
const { anchorElement } = this.props;
// If the element containing the anchor is scrolled dismiss the callout.
if (event.target && anchorElement && event.target.contains(anchorElement)) {
this.props.onDismiss && this.props.onDismiss();
}
}
else if (this.props.updateLayout) {
this.relayoutTimer.setTimeout(() => {
this.updateLayout();
}, 50);
}
}
}
};
// Track the element that had focus when we mounted.
this.focusElement = document.activeElement;
this.contentElement = props.contentRef || React.createRef();
}
render() {
const { blurDismiss, contentJustification, contentLocation, contentOrientation, focuszoneProps, lightDismiss, modal, onAnimationEnd, onMouseEnter, onMouseLeave, anchorElement } = this.props;
let content;
// If we have both a FocusWithin and a FocusZone we need to use the functional version
// of the FocusWithin to allow the FocusZone to contain the content directly.
if (blurDismiss && focuszoneProps) {
content = (React.createElement(FocusWithin, { onBlur: this.onBlur, updateStateOnFocusChange: false }, (props) => React.createElement(FocusZone, Object.assign({}, focuszoneProps), this.renderContent(props.onFocus, props.onBlur))));
}
else {
content = this.renderContent();
// Add the focus tracker to dismiss the callout if we are dismissing on blur.
if (blurDismiss) {
content = (React.createElement(FocusWithin, { onBlur: this.onBlur, updateStateOnFocusChange: false }, content));
}
// Add focus zone if focuszoneProperties are specified
if (focuszoneProps) {
content = React.createElement(FocusZone, Object.assign({}, focuszoneProps), content);
}
}
const lightDismissDiv = lightDismiss ? (React.createElement("div", { className: css("absolute-fill bolt-light-dismiss", modal && "bolt-callout-modal"), onClick: this.onClick })) : null;
// The callout is wrapped in a floating element in the portal.
// If lightDismiss is enabled we will create an absolute-fill div to capture onClick events.
return (React.createElement(SurfaceContext.Provider, { value: { background: SurfaceBackground.callout } },
React.createElement("div", { className: "flex-row flex-grow" },
React.createElement("div", { className: css(this.props.className, "bolt-callout absolute", contentLocation !== undefined && "absolute-fill", contentJustification === ContentJustification.Start && "justify-start", contentJustification === ContentJustification.Center && "justify-center", contentJustification === ContentJustification.End && "justify-end", contentLocation === ContentLocation.Start && "flex-start", contentLocation === ContentLocation.Center && "flex-center", contentLocation === ContentLocation.End && "flex-end", contentOrientation === ContentOrientation.Column && "flex-column", contentOrientation !== ContentOrientation.Column && "flex-row", modal && !lightDismiss && "bolt-callout-modal"), id: getSafeId(this.props.id), onAnimationEnd: onAnimationEnd, onMouseEnter: onMouseEnter, onMouseLeave: onMouseLeave, onKeyDown: this.onKeyDown, ref: this.calloutElement, role: this.props.role, tabIndex: -1 },
!anchorElement && lightDismissDiv,
content),
!!anchorElement && lightDismissDiv)));
}
componentDidMount() {
this.updateLayout();
// If this is an element relative layout we need to listen for scroll events
// on the document and dismiss the callout if the scroll event didnt pass
// through the callout.
if (this.props.anchorElement) {
window.addEventListener("resize", this.onResize);
document.addEventListener("scroll", this.onScrollDocument, true);
this.scrollListen = true;
}
}
componentDidUpdate() {
if (this.props.updateLayout) {
this.updateLayout();
}
}
componentWillUnmount() {
if (this.scrollListen) {
document.removeEventListener("scroll", this.onScrollDocument, true);
window.removeEventListener("resize", this.onResize);
}
if (this.relayoutTimer) {
this.relayoutTimer.clearAllTimers();
}
}
portalWillUnmount() {
const contentElement = this.contentElement.current;
const { focusElement } = this;
// If the callout has focus when unmounted we need to set focus back to the last element with focus.
// Need to wait for next tick otherwise focus/blur events are not fired.
if (focusElement && contentElement && contentElement.contains(document.activeElement)) {
window.setTimeout(() => {
// We need to make sure the active element is portal after the timeout.
// It may have moved through other means before the timeout expires.
// Set focus to the focusElement if our element contains focus, or if the focus has gone back to the document body
if (contentElement.contains(document.activeElement) || document.activeElement === document.body || document.activeElement === null) {
focusElement.focus();
}
}, 0);
}
}
updateLayout() {
if (this.props.contentLocation === undefined) {
if (this.calloutElement.current) {
// Position the element based on the initial layout parameters.
position(this.calloutElement.current, this.props.calloutOrigin || { horizontal: Location.start, vertical: Location.start }, this.props.anchorOffset, this.props.anchorElement, this.props.anchorOrigin, this.props.anchorPoint, this.props.anchorElement ? 5000 : 0);
// Now that the component is placed at the requested location, update
// the layout if the caller didnt request a fixed layout.
if (!this.props.fixedLayout) {
updateLayout(this.calloutElement.current, this.props.calloutOrigin || { horizontal: Location.start, vertical: Location.start }, this.props.anchorOffset, this.props.anchorElement, this.props.anchorOrigin, this.props.anchorPoint, this.props.anchorElement ? 5000 : 0);
}
}
}
}
renderContent(onFocus, onBlur) {
const { contentJustification, contentOrientation, contentSize, height, width } = this.props;
return (React.createElement("div", { "aria-describedby": getSafeId(this.props.ariaDescribedBy), "aria-label": this.props.ariaLabel, "aria-labelledby": getSafeId(this.props.ariaLabelledBy), "aria-modal": this.props.modal, className: css(this.props.contentClassName, "bolt-callout-content", this.props.contentShadow && "bolt-callout-shadow", contentJustification === ContentJustification.Stretch && "flex-grow", contentOrientation === ContentOrientation.Column && "flex-column", contentOrientation === ContentOrientation.Row && "flex-row", contentSize === ContentSize.Small && "bolt-callout-small", contentSize === ContentSize.Medium && "bolt-callout-medium", contentSize === ContentSize.Large && "bolt-callout-large", contentSize === ContentSize.ExtraLarge && "bolt-callout-extra-large", contentSize === ContentSize.Auto && "bolt-callout-auto"), onBlur: onBlur, onFocus: onFocus, onScroll: this.onScroll, ref: this.contentElement, role: this.props.role || "dialog", style: { height, width } }, this.props.children));
}
}