UNPKG

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