UNPKG

21.2 kBJavaScriptView Raw
1/*
2 * Copyright 2015 Palantir Technologies, Inc. All rights reserved.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16import { __assign, __decorate, __extends } from "tslib";
17import classNames from "classnames";
18import * as React from "react";
19import { findDOMNode } from "react-dom";
20import { polyfill } from "react-lifecycles-compat";
21import { CSSTransition, TransitionGroup } from "react-transition-group";
22import { AbstractPureComponent2, Classes, Keys } from "../../common";
23import { DISPLAYNAME_PREFIX } from "../../common/props";
24import { isFunction } from "../../common/utils";
25import { Portal } from "../portal/portal";
26// HACKHACK: https://github.com/palantir/blueprint/issues/4342
27// eslint-disable-next-line deprecation/deprecation
28var Overlay = /** @class */ (function (_super) {
29 __extends(Overlay, _super);
30 function Overlay() {
31 var _this = _super !== null && _super.apply(this, arguments) || this;
32 _this.isAutoFocusing = false;
33 _this.state = {
34 hasEverOpened: _this.props.isOpen,
35 };
36 // an HTMLElement that contains the backdrop and any children, to query for focus target
37 _this.containerElement = null;
38 // An empty, keyboard-focusable div at the beginning of the Overlay content
39 _this.startFocusTrapElement = null;
40 // An empty, keyboard-focusable div at the end of the Overlay content
41 _this.endFocusTrapElement = null;
42 _this.refHandlers = {
43 // HACKHACK: see https://github.com/palantir/blueprint/issues/3979
44 /* eslint-disable-next-line react/no-find-dom-node */
45 container: function (ref) { return (_this.containerElement = findDOMNode(ref)); },
46 endFocusTrap: function (ref) { return (_this.endFocusTrapElement = ref); },
47 startFocusTrap: function (ref) { return (_this.startFocusTrapElement = ref); },
48 };
49 _this.maybeRenderChild = function (child) {
50 if (isFunction(child)) {
51 child = child();
52 }
53 if (child == null) {
54 return null;
55 }
56 // add a special class to each child element that will automatically set the appropriate
57 // CSS position mode under the hood.
58 var decoratedChild = typeof child === "object" ? (React.cloneElement(child, {
59 className: classNames(child.props.className, Classes.OVERLAY_CONTENT),
60 })) : (React.createElement("span", { className: Classes.OVERLAY_CONTENT }, child));
61 var _a = _this.props, onOpening = _a.onOpening, onOpened = _a.onOpened, onClosing = _a.onClosing, transitionDuration = _a.transitionDuration, transitionName = _a.transitionName;
62 // a breaking change in react-transition-group types requires us to be explicit about the type overload here,
63 // using a technique similar to Select.ofType() in @blueprintjs/select
64 var CSSTransitionImplicit = CSSTransition;
65 return (React.createElement(CSSTransitionImplicit, { classNames: transitionName, onEntering: onOpening, onEntered: onOpened, onExiting: onClosing, onExited: _this.handleTransitionExited, timeout: transitionDuration, addEndListener: _this.handleTransitionAddEnd }, decoratedChild));
66 };
67 /**
68 * Ensures repeatedly pressing shift+tab keeps focus inside the Overlay. Moves focus to
69 * the `endFocusTrapElement` or the first keyboard-focusable element in the Overlay (excluding
70 * the `startFocusTrapElement`), depending on whether the element losing focus is inside the
71 * Overlay.
72 */
73 _this.handleStartFocusTrapElementFocus = function (e) {
74 var _a;
75 if (!_this.props.enforceFocus || _this.isAutoFocusing) {
76 return;
77 }
78 // e.relatedTarget will not be defined if this was a programmatic focus event, as is the
79 // case when we call this.bringFocusInsideOverlay() after a user clicked on the backdrop.
80 // Otherwise, we're handling a user interaction, and we should wrap around to the last
81 // element in this transition group.
82 if (e.relatedTarget != null &&
83 _this.containerElement.contains(e.relatedTarget) &&
84 e.relatedTarget !== _this.endFocusTrapElement) {
85 (_a = _this.endFocusTrapElement) === null || _a === void 0 ? void 0 : _a.focus();
86 }
87 };
88 /**
89 * Wrap around to the end of the dialog if `enforceFocus` is enabled.
90 */
91 _this.handleStartFocusTrapElementKeyDown = function (e) {
92 var _a;
93 if (!_this.props.enforceFocus) {
94 return;
95 }
96 // HACKHACK: https://github.com/palantir/blueprint/issues/4165
97 /* eslint-disable-next-line deprecation/deprecation */
98 if (e.shiftKey && e.which === Keys.TAB) {
99 var lastFocusableElement = _this.getKeyboardFocusableElements().pop();
100 if (lastFocusableElement != null) {
101 lastFocusableElement.focus();
102 }
103 else {
104 (_a = _this.endFocusTrapElement) === null || _a === void 0 ? void 0 : _a.focus();
105 }
106 }
107 };
108 /**
109 * Ensures repeatedly pressing tab keeps focus inside the Overlay. Moves focus to the
110 * `startFocusTrapElement` or the last keyboard-focusable element in the Overlay (excluding the
111 * `startFocusTrapElement`), depending on whether the element losing focus is inside the
112 * Overlay.
113 */
114 _this.handleEndFocusTrapElementFocus = function (e) {
115 var _a, _b;
116 // No need for this.props.enforceFocus check here because this element is only rendered
117 // when that prop is true.
118 // During user interactions, e.relatedTarget will be defined, and we should wrap around to the
119 // "start focus trap" element.
120 // Otherwise, we're handling a programmatic focus event, which can only happen after a user
121 // presses shift+tab from the first focusable element in the overlay.
122 if (e.relatedTarget != null &&
123 _this.containerElement.contains(e.relatedTarget) &&
124 e.relatedTarget !== _this.startFocusTrapElement) {
125 var firstFocusableElement = _this.getKeyboardFocusableElements().shift();
126 // ensure we don't re-focus an already active element by comparing against e.relatedTarget
127 if (!_this.isAutoFocusing && firstFocusableElement != null && firstFocusableElement !== e.relatedTarget) {
128 firstFocusableElement.focus();
129 }
130 else {
131 (_a = _this.startFocusTrapElement) === null || _a === void 0 ? void 0 : _a.focus();
132 }
133 }
134 else {
135 var lastFocusableElement = _this.getKeyboardFocusableElements().pop();
136 if (lastFocusableElement != null) {
137 lastFocusableElement.focus();
138 }
139 else {
140 // Keeps focus within Overlay even if there are no keyboard-focusable children
141 (_b = _this.startFocusTrapElement) === null || _b === void 0 ? void 0 : _b.focus();
142 }
143 }
144 };
145 _this.handleTransitionExited = function (node) {
146 var _a, _b;
147 if (_this.props.shouldReturnFocusOnClose && _this.lastActiveElementBeforeOpened instanceof HTMLElement) {
148 _this.lastActiveElementBeforeOpened.focus();
149 }
150 (_b = (_a = _this.props).onClosed) === null || _b === void 0 ? void 0 : _b.call(_a, node);
151 };
152 _this.handleBackdropMouseDown = function (e) {
153 var _a;
154 var _b = _this.props, backdropProps = _b.backdropProps, canOutsideClickClose = _b.canOutsideClickClose, enforceFocus = _b.enforceFocus, onClose = _b.onClose;
155 if (canOutsideClickClose) {
156 onClose === null || onClose === void 0 ? void 0 : onClose(e);
157 }
158 if (enforceFocus) {
159 _this.bringFocusInsideOverlay();
160 }
161 (_a = backdropProps === null || backdropProps === void 0 ? void 0 : backdropProps.onMouseDown) === null || _a === void 0 ? void 0 : _a.call(backdropProps, e);
162 };
163 _this.handleDocumentClick = function (e) {
164 var _a = _this.props, canOutsideClickClose = _a.canOutsideClickClose, isOpen = _a.isOpen, onClose = _a.onClose;
165 // get the actual target even in the Shadow DOM
166 var eventTarget = (e.composed ? e.composedPath()[0] : e.target);
167 var stackIndex = Overlay_1.openStack.indexOf(_this);
168 var isClickInThisOverlayOrDescendant = Overlay_1.openStack
169 .slice(stackIndex)
170 .some(function (_a) {
171 var elem = _a.containerElement;
172 // `elem` is the container of backdrop & content, so clicking on that container
173 // should not count as being "inside" the overlay.
174 return elem && elem.contains(eventTarget) && !elem.isSameNode(eventTarget);
175 });
176 if (isOpen && !isClickInThisOverlayOrDescendant && canOutsideClickClose) {
177 // casting to any because this is a native event
178 onClose === null || onClose === void 0 ? void 0 : onClose(e);
179 }
180 };
181 /**
182 * When multiple Overlays are open, this event handler is only active for the most recently
183 * opened one to avoid Overlays competing with each other for focus.
184 */
185 _this.handleDocumentFocus = function (e) {
186 // get the actual target even in the Shadow DOM
187 var eventTarget = e.composed ? e.composedPath()[0] : e.target;
188 if (_this.props.enforceFocus &&
189 _this.containerElement != null &&
190 eventTarget instanceof Node &&
191 !_this.containerElement.contains(eventTarget)) {
192 // prevent default focus behavior (sometimes auto-scrolls the page)
193 e.preventDefault();
194 e.stopImmediatePropagation();
195 _this.bringFocusInsideOverlay();
196 }
197 };
198 _this.handleKeyDown = function (e) {
199 var _a = _this.props, canEscapeKeyClose = _a.canEscapeKeyClose, onClose = _a.onClose;
200 // HACKHACK: https://github.com/palantir/blueprint/issues/4165
201 /* eslint-disable-next-line deprecation/deprecation */
202 if (e.which === Keys.ESCAPE && canEscapeKeyClose) {
203 onClose === null || onClose === void 0 ? void 0 : onClose(e);
204 // prevent browser-specific escape key behavior (Safari exits fullscreen)
205 e.preventDefault();
206 }
207 };
208 _this.handleTransitionAddEnd = function () {
209 // no-op
210 };
211 return _this;
212 }
213 Overlay_1 = Overlay;
214 Overlay.getDerivedStateFromProps = function (_a) {
215 var hasEverOpened = _a.isOpen;
216 if (hasEverOpened) {
217 return { hasEverOpened: hasEverOpened };
218 }
219 return null;
220 };
221 Overlay.prototype.render = function () {
222 var _a;
223 var _b;
224 // oh snap! no reason to render anything at all if we're being truly lazy
225 if (this.props.lazy && !this.state.hasEverOpened) {
226 return null;
227 }
228 var _c = this.props, autoFocus = _c.autoFocus, children = _c.children, className = _c.className, enforceFocus = _c.enforceFocus, usePortal = _c.usePortal, isOpen = _c.isOpen;
229 // TransitionGroup types require single array of children; does not support nested arrays.
230 // So we must collapse backdrop and children into one array, and every item must be wrapped in a
231 // Transition element (no ReactText allowed).
232 var childrenWithTransitions = isOpen ? (_b = React.Children.map(children, this.maybeRenderChild)) !== null && _b !== void 0 ? _b : [] : [];
233 var maybeBackdrop = this.maybeRenderBackdrop();
234 if (maybeBackdrop !== null) {
235 childrenWithTransitions.unshift(maybeBackdrop);
236 }
237 if (isOpen && (autoFocus || enforceFocus) && childrenWithTransitions.length > 0) {
238 childrenWithTransitions.unshift(this.renderDummyElement("__start", {
239 className: Classes.OVERLAY_START_FOCUS_TRAP,
240 onFocus: this.handleStartFocusTrapElementFocus,
241 onKeyDown: this.handleStartFocusTrapElementKeyDown,
242 ref: this.refHandlers.startFocusTrap,
243 }));
244 if (enforceFocus) {
245 childrenWithTransitions.push(this.renderDummyElement("__end", {
246 className: Classes.OVERLAY_END_FOCUS_TRAP,
247 onFocus: this.handleEndFocusTrapElementFocus,
248 ref: this.refHandlers.endFocusTrap,
249 }));
250 }
251 }
252 var containerClasses = classNames(Classes.OVERLAY, (_a = {},
253 _a[Classes.OVERLAY_OPEN] = isOpen,
254 _a[Classes.OVERLAY_INLINE] = !usePortal,
255 _a), className);
256 var transitionGroup = (React.createElement(TransitionGroup, { appear: true, "aria-live": "polite", className: containerClasses, component: "div", onKeyDown: this.handleKeyDown, ref: this.refHandlers.container }, childrenWithTransitions));
257 if (usePortal) {
258 return (React.createElement(Portal, { className: this.props.portalClassName, container: this.props.portalContainer }, transitionGroup));
259 }
260 else {
261 return transitionGroup;
262 }
263 };
264 Overlay.prototype.componentDidMount = function () {
265 if (this.props.isOpen) {
266 this.overlayWillOpen();
267 }
268 };
269 Overlay.prototype.componentDidUpdate = function (prevProps) {
270 if (prevProps.isOpen && !this.props.isOpen) {
271 this.overlayWillClose();
272 }
273 else if (!prevProps.isOpen && this.props.isOpen) {
274 this.overlayWillOpen();
275 }
276 };
277 Overlay.prototype.componentWillUnmount = function () {
278 this.overlayWillClose();
279 };
280 /**
281 * @public for testing
282 * @internal
283 */
284 Overlay.prototype.bringFocusInsideOverlay = function () {
285 var _this = this;
286 // always delay focus manipulation to just before repaint to prevent scroll jumping
287 return this.requestAnimationFrame(function () {
288 var _a;
289 // container ref may be undefined between component mounting and Portal rendering
290 // activeElement may be undefined in some rare cases in IE
291 if (_this.containerElement == null || document.activeElement == null || !_this.props.isOpen) {
292 return;
293 }
294 var isFocusOutsideModal = !_this.containerElement.contains(document.activeElement);
295 if (isFocusOutsideModal) {
296 (_a = _this.startFocusTrapElement) === null || _a === void 0 ? void 0 : _a.focus();
297 _this.isAutoFocusing = false;
298 }
299 });
300 };
301 Overlay.prototype.maybeRenderBackdrop = function () {
302 var _a = this.props, backdropClassName = _a.backdropClassName, backdropProps = _a.backdropProps, hasBackdrop = _a.hasBackdrop, isOpen = _a.isOpen, transitionDuration = _a.transitionDuration, transitionName = _a.transitionName;
303 if (hasBackdrop && isOpen) {
304 return (React.createElement(CSSTransition, { classNames: transitionName, key: "__backdrop", timeout: transitionDuration, addEndListener: this.handleTransitionAddEnd },
305 React.createElement("div", __assign({}, backdropProps, { className: classNames(Classes.OVERLAY_BACKDROP, backdropClassName, backdropProps === null || backdropProps === void 0 ? void 0 : backdropProps.className), onMouseDown: this.handleBackdropMouseDown }))));
306 }
307 else {
308 return null;
309 }
310 };
311 Overlay.prototype.renderDummyElement = function (key, props) {
312 var _a = this.props, transitionDuration = _a.transitionDuration, transitionName = _a.transitionName;
313 return (React.createElement(CSSTransition, { classNames: transitionName, key: key, addEndListener: this.handleTransitionAddEnd, timeout: transitionDuration, unmountOnExit: true },
314 React.createElement("div", __assign({ tabIndex: 0 }, props))));
315 };
316 Overlay.prototype.getKeyboardFocusableElements = function () {
317 var focusableElements = this.containerElement !== null
318 ? Array.from(
319 // Order may not be correct if children elements use tabindex values > 0.
320 // Selectors derived from this SO question:
321 // https://stackoverflow.com/questions/1599660/which-html-elements-can-receive-focus
322 this.containerElement.querySelectorAll([
323 'a[href]:not([tabindex="-1"])',
324 'button:not([disabled]):not([tabindex="-1"])',
325 'details:not([tabindex="-1"])',
326 'input:not([disabled]):not([tabindex="-1"])',
327 'select:not([disabled]):not([tabindex="-1"])',
328 'textarea:not([disabled]):not([tabindex="-1"])',
329 '[tabindex]:not([tabindex="-1"])',
330 ].join(",")))
331 : [];
332 return focusableElements.filter(function (el) {
333 return !el.classList.contains(Classes.OVERLAY_START_FOCUS_TRAP) &&
334 !el.classList.contains(Classes.OVERLAY_END_FOCUS_TRAP);
335 });
336 };
337 Overlay.prototype.overlayWillClose = function () {
338 document.removeEventListener("focus", this.handleDocumentFocus, /* useCapture */ true);
339 document.removeEventListener("mousedown", this.handleDocumentClick);
340 var openStack = Overlay_1.openStack;
341 var stackIndex = openStack.indexOf(this);
342 if (stackIndex !== -1) {
343 openStack.splice(stackIndex, 1);
344 if (openStack.length > 0) {
345 var lastOpenedOverlay = Overlay_1.getLastOpened();
346 if (lastOpenedOverlay.props.enforceFocus) {
347 lastOpenedOverlay.bringFocusInsideOverlay();
348 document.addEventListener("focus", lastOpenedOverlay.handleDocumentFocus, /* useCapture */ true);
349 }
350 }
351 if (openStack.filter(function (o) { return o.props.usePortal && o.props.hasBackdrop; }).length === 0) {
352 document.body.classList.remove(Classes.OVERLAY_OPEN);
353 }
354 }
355 };
356 Overlay.prototype.overlayWillOpen = function () {
357 var getLastOpened = Overlay_1.getLastOpened, openStack = Overlay_1.openStack;
358 if (openStack.length > 0) {
359 document.removeEventListener("focus", getLastOpened().handleDocumentFocus, /* useCapture */ true);
360 }
361 openStack.push(this);
362 if (this.props.autoFocus) {
363 this.isAutoFocusing = true;
364 this.bringFocusInsideOverlay();
365 }
366 if (this.props.enforceFocus) {
367 // Focus events do not bubble, but setting useCapture allows us to listen in and execute
368 // our handler before all others
369 document.addEventListener("focus", this.handleDocumentFocus, /* useCapture */ true);
370 }
371 if (this.props.canOutsideClickClose && !this.props.hasBackdrop) {
372 document.addEventListener("mousedown", this.handleDocumentClick);
373 }
374 if (this.props.hasBackdrop && this.props.usePortal) {
375 // add a class to the body to prevent scrolling of content below the overlay
376 document.body.classList.add(Classes.OVERLAY_OPEN);
377 }
378 this.lastActiveElementBeforeOpened = document.activeElement;
379 };
380 var Overlay_1;
381 Overlay.displayName = DISPLAYNAME_PREFIX + ".Overlay";
382 Overlay.defaultProps = {
383 autoFocus: true,
384 backdropProps: {},
385 canEscapeKeyClose: true,
386 canOutsideClickClose: true,
387 enforceFocus: true,
388 hasBackdrop: true,
389 isOpen: false,
390 lazy: true,
391 shouldReturnFocusOnClose: true,
392 transitionDuration: 300,
393 transitionName: Classes.OVERLAY,
394 usePortal: true,
395 };
396 Overlay.openStack = [];
397 Overlay.getLastOpened = function () { return Overlay_1.openStack[Overlay_1.openStack.length - 1]; };
398 Overlay = Overlay_1 = __decorate([
399 polyfill
400 ], Overlay);
401 return Overlay;
402}(AbstractPureComponent2));
403export { Overlay };
404//# sourceMappingURL=overlay.js.map
\No newline at end of file