UNPKG

22.6 kBJavaScriptView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3import { Button, closeIcon, LabIcon } from '@jupyterlab/ui-components';
4import { ArrayExt, each, map, toArray } from '@lumino/algorithm';
5import { PromiseDelegate } from '@lumino/coreutils';
6import { MessageLoop } from '@lumino/messaging';
7import { Panel, PanelLayout, Widget } from '@lumino/widgets';
8import * as React from 'react';
9import { Styling } from './styling';
10import { ReactWidget } from './vdom';
11import { WidgetTracker } from './widgettracker';
12/**
13 * Create and show a dialog.
14 *
15 * @param options - The dialog setup options.
16 *
17 * @returns A promise that resolves with whether the dialog was accepted.
18 */
19export function showDialog(options = {}) {
20 const dialog = new Dialog(options);
21 return dialog.launch();
22}
23/**
24 * Show an error message dialog.
25 *
26 * @param title - The title of the dialog box.
27 *
28 * @param error - the error to show in the dialog body (either a string
29 * or an object with a string `message` property).
30 */
31export function showErrorMessage(title, error, buttons = [
32 Dialog.okButton({ label: 'Dismiss' })
33]) {
34 console.warn('Showing error:', error);
35 // Cache promises to prevent multiple copies of identical dialogs showing
36 // to the user.
37 const body = typeof error === 'string' ? error : error.message;
38 const key = title + '----' + body;
39 const promise = Private.errorMessagePromiseCache.get(key);
40 if (promise) {
41 return promise;
42 }
43 else {
44 const dialogPromise = showDialog({
45 title: title,
46 body: body,
47 buttons: buttons
48 }).then(() => {
49 Private.errorMessagePromiseCache.delete(key);
50 }, error => {
51 // TODO: Use .finally() above when supported
52 Private.errorMessagePromiseCache.delete(key);
53 throw error;
54 });
55 Private.errorMessagePromiseCache.set(key, dialogPromise);
56 return dialogPromise;
57 }
58}
59/**
60 * A modal dialog widget.
61 */
62export class Dialog extends Widget {
63 /**
64 * Create a dialog panel instance.
65 *
66 * @param options - The dialog setup options.
67 */
68 constructor(options = {}) {
69 super();
70 this._focusNodeSelector = '';
71 this.addClass('jp-Dialog');
72 const normalized = Private.handleOptions(options);
73 const renderer = normalized.renderer;
74 this._host = normalized.host;
75 this._defaultButton = normalized.defaultButton;
76 this._buttons = normalized.buttons;
77 this._hasClose = normalized.hasClose;
78 this._buttonNodes = toArray(map(this._buttons, button => {
79 return renderer.createButtonNode(button);
80 }));
81 this._lastMouseDownInDialog = false;
82 const layout = (this.layout = new PanelLayout());
83 const content = new Panel();
84 content.addClass('jp-Dialog-content');
85 if (typeof options.body === 'string') {
86 content.addClass('jp-Dialog-content-small');
87 }
88 layout.addWidget(content);
89 this._body = normalized.body;
90 const header = renderer.createHeader(normalized.title, () => this.reject(), options);
91 const body = renderer.createBody(normalized.body);
92 const footer = renderer.createFooter(this._buttonNodes);
93 content.addWidget(header);
94 content.addWidget(body);
95 content.addWidget(footer);
96 this._primary = this._buttonNodes[this._defaultButton];
97 this._focusNodeSelector = options.focusNodeSelector;
98 // Add new dialogs to the tracker.
99 void Dialog.tracker.add(this);
100 }
101 /**
102 * Dispose of the resources used by the dialog.
103 */
104 dispose() {
105 const promise = this._promise;
106 if (promise) {
107 this._promise = null;
108 promise.reject(void 0);
109 ArrayExt.removeFirstOf(Private.launchQueue, promise.promise);
110 }
111 super.dispose();
112 }
113 /**
114 * Launch the dialog as a modal window.
115 *
116 * @returns a promise that resolves with the result of the dialog.
117 */
118 launch() {
119 // Return the existing dialog if already open.
120 if (this._promise) {
121 return this._promise.promise;
122 }
123 const promise = (this._promise = new PromiseDelegate());
124 const promises = Promise.all(Private.launchQueue);
125 Private.launchQueue.push(this._promise.promise);
126 return promises.then(() => {
127 // Do not show Dialog if it was disposed of before it was at the front of the launch queue
128 if (!this._promise) {
129 return Promise.resolve({ button: Dialog.cancelButton(), value: null });
130 }
131 Widget.attach(this, this._host);
132 return promise.promise;
133 });
134 }
135 /**
136 * Resolve the current dialog.
137 *
138 * @param index - An optional index to the button to resolve.
139 *
140 * #### Notes
141 * Will default to the defaultIndex.
142 * Will resolve the current `show()` with the button value.
143 * Will be a no-op if the dialog is not shown.
144 */
145 resolve(index) {
146 if (!this._promise) {
147 return;
148 }
149 if (index === undefined) {
150 index = this._defaultButton;
151 }
152 this._resolve(this._buttons[index]);
153 }
154 /**
155 * Reject the current dialog with a default reject value.
156 *
157 * #### Notes
158 * Will be a no-op if the dialog is not shown.
159 */
160 reject() {
161 if (!this._promise) {
162 return;
163 }
164 this._resolve(Dialog.cancelButton());
165 }
166 /**
167 * Handle the DOM events for the directory listing.
168 *
169 * @param event - The DOM event sent to the widget.
170 *
171 * #### Notes
172 * This method implements the DOM `EventListener` interface and is
173 * called in response to events on the panel's DOM node. It should
174 * not be called directly by user code.
175 */
176 handleEvent(event) {
177 switch (event.type) {
178 case 'keydown':
179 this._evtKeydown(event);
180 break;
181 case 'mousedown':
182 this._evtMouseDown(event);
183 break;
184 case 'click':
185 this._evtClick(event);
186 break;
187 case 'focus':
188 this._evtFocus(event);
189 break;
190 case 'contextmenu':
191 event.preventDefault();
192 event.stopPropagation();
193 break;
194 default:
195 break;
196 }
197 }
198 /**
199 * A message handler invoked on an `'after-attach'` message.
200 */
201 onAfterAttach(msg) {
202 var _a;
203 const node = this.node;
204 node.addEventListener('keydown', this, true);
205 node.addEventListener('contextmenu', this, true);
206 node.addEventListener('click', this, true);
207 document.addEventListener('mousedown', this, true);
208 document.addEventListener('focus', this, true);
209 this._first = Private.findFirstFocusable(this.node);
210 this._original = document.activeElement;
211 if (this._focusNodeSelector) {
212 const body = this.node.querySelector('.jp-Dialog-body');
213 const el = body === null || body === void 0 ? void 0 : body.querySelector(this._focusNodeSelector);
214 if (el) {
215 this._primary = el;
216 }
217 }
218 (_a = this._primary) === null || _a === void 0 ? void 0 : _a.focus();
219 }
220 /**
221 * A message handler invoked on an `'after-detach'` message.
222 */
223 onAfterDetach(msg) {
224 const node = this.node;
225 node.removeEventListener('keydown', this, true);
226 node.removeEventListener('contextmenu', this, true);
227 node.removeEventListener('click', this, true);
228 document.removeEventListener('focus', this, true);
229 document.removeEventListener('mousedown', this, true);
230 this._original.focus();
231 }
232 /**
233 * A message handler invoked on a `'close-request'` message.
234 */
235 onCloseRequest(msg) {
236 if (this._promise) {
237 this.reject();
238 }
239 super.onCloseRequest(msg);
240 }
241 /**
242 * Handle the `'click'` event for a dialog button.
243 *
244 * @param event - The DOM event sent to the widget
245 */
246 _evtClick(event) {
247 const content = this.node.getElementsByClassName('jp-Dialog-content')[0];
248 if (!content.contains(event.target)) {
249 event.stopPropagation();
250 event.preventDefault();
251 if (this._hasClose && !this._lastMouseDownInDialog) {
252 this.reject();
253 }
254 return;
255 }
256 for (const buttonNode of this._buttonNodes) {
257 if (buttonNode.contains(event.target)) {
258 const index = this._buttonNodes.indexOf(buttonNode);
259 this.resolve(index);
260 }
261 }
262 }
263 /**
264 * Handle the `'keydown'` event for the widget.
265 *
266 * @param event - The DOM event sent to the widget
267 */
268 _evtKeydown(event) {
269 // Check for escape key
270 switch (event.keyCode) {
271 case 27: // Escape.
272 event.stopPropagation();
273 event.preventDefault();
274 if (this._hasClose) {
275 this.reject();
276 }
277 break;
278 case 37: {
279 // Left arrow
280 const activeEl = document.activeElement;
281 if (activeEl instanceof HTMLButtonElement) {
282 let idx = this._buttonNodes.indexOf(activeEl) - 1;
283 // Handle a left arrows on the first button
284 if (idx < 0) {
285 idx = this._buttonNodes.length - 1;
286 }
287 const node = this._buttonNodes[idx];
288 event.stopPropagation();
289 event.preventDefault();
290 node.focus();
291 }
292 break;
293 }
294 case 39: {
295 // Right arrow
296 const activeEl = document.activeElement;
297 if (activeEl instanceof HTMLButtonElement) {
298 let idx = this._buttonNodes.indexOf(activeEl) + 1;
299 // Handle a right arrows on the last button
300 if (idx == this._buttons.length) {
301 idx = 0;
302 }
303 const node = this._buttonNodes[idx];
304 event.stopPropagation();
305 event.preventDefault();
306 node.focus();
307 }
308 break;
309 }
310 case 9: {
311 // Tab.
312 // Handle a tab on the last button.
313 const node = this._buttonNodes[this._buttons.length - 1];
314 if (document.activeElement === node && !event.shiftKey) {
315 event.stopPropagation();
316 event.preventDefault();
317 this._first.focus();
318 }
319 break;
320 }
321 case 13: {
322 // Enter.
323 event.stopPropagation();
324 event.preventDefault();
325 const activeEl = document.activeElement;
326 let index;
327 if (activeEl instanceof HTMLButtonElement) {
328 index = this._buttonNodes.indexOf(activeEl);
329 }
330 this.resolve(index);
331 break;
332 }
333 default:
334 break;
335 }
336 }
337 /**
338 * Handle the `'focus'` event for the widget.
339 *
340 * @param event - The DOM event sent to the widget
341 */
342 _evtFocus(event) {
343 var _a;
344 const target = event.target;
345 if (!this.node.contains(target)) {
346 event.stopPropagation();
347 (_a = this._buttonNodes[this._defaultButton]) === null || _a === void 0 ? void 0 : _a.focus();
348 }
349 }
350 /**
351 * Handle the `'mousedown'` event for the widget.
352 *
353 * @param event - The DOM event sent to the widget
354 */
355 _evtMouseDown(event) {
356 const content = this.node.getElementsByClassName('jp-Dialog-content')[0];
357 const target = event.target;
358 this._lastMouseDownInDialog = content.contains(target);
359 }
360 /**
361 * Resolve a button item.
362 */
363 _resolve(button) {
364 // Prevent loopback.
365 const promise = this._promise;
366 if (!promise) {
367 this.dispose();
368 return;
369 }
370 this._promise = null;
371 ArrayExt.removeFirstOf(Private.launchQueue, promise.promise);
372 const body = this._body;
373 let value = null;
374 if (button.accept &&
375 body instanceof Widget &&
376 typeof body.getValue === 'function') {
377 value = body.getValue();
378 }
379 this.dispose();
380 promise.resolve({ button, value });
381 }
382}
383/**
384 * The namespace for Dialog class statics.
385 */
386(function (Dialog) {
387 /**
388 * Create a button item.
389 */
390 function createButton(value) {
391 value.accept = value.accept !== false;
392 const defaultLabel = value.accept ? 'OK' : 'Cancel';
393 return {
394 label: value.label || defaultLabel,
395 iconClass: value.iconClass || '',
396 iconLabel: value.iconLabel || '',
397 caption: value.caption || '',
398 className: value.className || '',
399 accept: value.accept,
400 actions: value.actions || [],
401 displayType: value.displayType || 'default'
402 };
403 }
404 Dialog.createButton = createButton;
405 /**
406 * Create a reject button.
407 */
408 function cancelButton(options = {}) {
409 options.accept = false;
410 return createButton(options);
411 }
412 Dialog.cancelButton = cancelButton;
413 /**
414 * Create an accept button.
415 */
416 function okButton(options = {}) {
417 options.accept = true;
418 return createButton(options);
419 }
420 Dialog.okButton = okButton;
421 /**
422 * Create a warn button.
423 */
424 function warnButton(options = {}) {
425 options.displayType = 'warn';
426 return createButton(options);
427 }
428 Dialog.warnButton = warnButton;
429 /**
430 * Disposes all dialog instances.
431 *
432 * #### Notes
433 * This function should only be used in tests or cases where application state
434 * may be discarded.
435 */
436 function flush() {
437 Dialog.tracker.forEach(dialog => {
438 dialog.dispose();
439 });
440 }
441 Dialog.flush = flush;
442 /**
443 * The default implementation of a dialog renderer.
444 */
445 class Renderer {
446 /**
447 * Create the header of the dialog.
448 *
449 * @param title - The title of the dialog.
450 *
451 * @returns A widget for the dialog header.
452 */
453 createHeader(title, reject = () => {
454 /* empty */
455 }, options = {}) {
456 let header;
457 const handleMouseDown = (event) => {
458 // Fire action only when left button is pressed.
459 if (event.button === 0) {
460 event.preventDefault();
461 reject();
462 }
463 };
464 const handleKeyDown = (event) => {
465 const { key } = event;
466 if (key === 'Enter' || key === ' ') {
467 reject();
468 }
469 };
470 if (typeof title === 'string') {
471 header = ReactWidget.create(React.createElement(React.Fragment, null,
472 title,
473 options.hasClose && (React.createElement(Button, { className: "jp-Dialog-close-button", onMouseDown: handleMouseDown, onKeyDown: handleKeyDown, title: "Cancel", minimal: true },
474 React.createElement(LabIcon.resolveReact, { icon: closeIcon, iconClass: "jp-Icon", className: "jp-ToolbarButtonComponent-icon", tag: "span" })))));
475 }
476 else {
477 header = ReactWidget.create(title);
478 }
479 header.addClass('jp-Dialog-header');
480 Styling.styleNode(header.node);
481 return header;
482 }
483 /**
484 * Create the body of the dialog.
485 *
486 * @param value - The input value for the body.
487 *
488 * @returns A widget for the body.
489 */
490 createBody(value) {
491 let body;
492 if (typeof value === 'string') {
493 body = new Widget({ node: document.createElement('span') });
494 body.node.textContent = value;
495 }
496 else if (value instanceof Widget) {
497 body = value;
498 }
499 else {
500 body = ReactWidget.create(value);
501 // Immediately update the body even though it has not yet attached in
502 // order to trigger a render of the DOM nodes from the React element.
503 MessageLoop.sendMessage(body, Widget.Msg.UpdateRequest);
504 }
505 body.addClass('jp-Dialog-body');
506 Styling.styleNode(body.node);
507 return body;
508 }
509 /**
510 * Create the footer of the dialog.
511 *
512 * @param buttonNodes - The buttons nodes to add to the footer.
513 *
514 * @returns A widget for the footer.
515 */
516 createFooter(buttons) {
517 const footer = new Widget();
518 footer.addClass('jp-Dialog-footer');
519 each(buttons, button => {
520 footer.node.appendChild(button);
521 });
522 Styling.styleNode(footer.node);
523 return footer;
524 }
525 /**
526 * Create a button node for the dialog.
527 *
528 * @param button - The button data.
529 *
530 * @returns A node for the button.
531 */
532 createButtonNode(button) {
533 const e = document.createElement('button');
534 e.className = this.createItemClass(button);
535 e.appendChild(this.renderIcon(button));
536 e.appendChild(this.renderLabel(button));
537 return e;
538 }
539 /**
540 * Create the class name for the button.
541 *
542 * @param data - The data to use for the class name.
543 *
544 * @returns The full class name for the button.
545 */
546 createItemClass(data) {
547 // Setup the initial class name.
548 let name = 'jp-Dialog-button';
549 // Add the other state classes.
550 if (data.accept) {
551 name += ' jp-mod-accept';
552 }
553 else {
554 name += ' jp-mod-reject';
555 }
556 if (data.displayType === 'warn') {
557 name += ' jp-mod-warn';
558 }
559 // Add the extra class.
560 const extra = data.className;
561 if (extra) {
562 name += ` ${extra}`;
563 }
564 // Return the complete class name.
565 return name;
566 }
567 /**
568 * Render an icon element for a dialog item.
569 *
570 * @param data - The data to use for rendering the icon.
571 *
572 * @returns An HTML element representing the icon.
573 */
574 renderIcon(data) {
575 const e = document.createElement('div');
576 e.className = this.createIconClass(data);
577 e.appendChild(document.createTextNode(data.iconLabel));
578 return e;
579 }
580 /**
581 * Create the class name for the button icon.
582 *
583 * @param data - The data to use for the class name.
584 *
585 * @returns The full class name for the item icon.
586 */
587 createIconClass(data) {
588 const name = 'jp-Dialog-buttonIcon';
589 const extra = data.iconClass;
590 return extra ? `${name} ${extra}` : name;
591 }
592 /**
593 * Render the label element for a button.
594 *
595 * @param data - The data to use for rendering the label.
596 *
597 * @returns An HTML element representing the item label.
598 */
599 renderLabel(data) {
600 const e = document.createElement('div');
601 e.className = 'jp-Dialog-buttonLabel';
602 e.title = data.caption;
603 e.appendChild(document.createTextNode(data.label));
604 return e;
605 }
606 }
607 Dialog.Renderer = Renderer;
608 /**
609 * The default renderer instance.
610 */
611 Dialog.defaultRenderer = new Renderer();
612 /**
613 * The dialog widget tracker.
614 */
615 Dialog.tracker = new WidgetTracker({
616 namespace: '@jupyterlab/apputils:Dialog'
617 });
618})(Dialog || (Dialog = {}));
619/**
620 * The namespace for module private data.
621 */
622var Private;
623(function (Private) {
624 /**
625 * The queue for launching dialogs.
626 */
627 Private.launchQueue = [];
628 Private.errorMessagePromiseCache = new Map();
629 /**
630 * Handle the input options for a dialog.
631 *
632 * @param options - The input options.
633 *
634 * @returns A new options object with defaults applied.
635 */
636 function handleOptions(options = {}) {
637 var _a, _b, _c, _d, _e, _f, _g, _h;
638 const buttons = (_a = options.buttons) !== null && _a !== void 0 ? _a : [
639 Dialog.cancelButton(),
640 Dialog.okButton()
641 ];
642 return {
643 title: (_b = options.title) !== null && _b !== void 0 ? _b : '',
644 body: (_c = options.body) !== null && _c !== void 0 ? _c : '',
645 host: (_d = options.host) !== null && _d !== void 0 ? _d : document.body,
646 buttons,
647 defaultButton: (_e = options.defaultButton) !== null && _e !== void 0 ? _e : buttons.length - 1,
648 renderer: (_f = options.renderer) !== null && _f !== void 0 ? _f : Dialog.defaultRenderer,
649 focusNodeSelector: (_g = options.focusNodeSelector) !== null && _g !== void 0 ? _g : '',
650 hasClose: (_h = options.hasClose) !== null && _h !== void 0 ? _h : true
651 };
652 }
653 Private.handleOptions = handleOptions;
654 /**
655 * Find the first focusable item in the dialog.
656 */
657 function findFirstFocusable(node) {
658 const candidateSelectors = [
659 'input',
660 'select',
661 'a[href]',
662 'textarea',
663 'button',
664 '[tabindex]'
665 ].join(',');
666 return node.querySelectorAll(candidateSelectors)[0];
667 }
668 Private.findFirstFocusable = findFirstFocusable;
669})(Private || (Private = {}));
670//# sourceMappingURL=dialog.js.map
\No newline at end of file