UNPKG

30.6 kBTypeScriptView Raw
1// Copyright (c) Jupyter Development Team.
2// Distributed under the terms of the Modified BSD License.
3
4import { ITranslator, nullTranslator } from '@jupyterlab/translation';
5import {
6 Button,
7 closeIcon,
8 LabIcon,
9 ReactWidget,
10 Styling
11} from '@jupyterlab/ui-components';
12import { ArrayExt } from '@lumino/algorithm';
13import { PromiseDelegate } from '@lumino/coreutils';
14import { Message, MessageLoop } from '@lumino/messaging';
15import { Panel, PanelLayout, Widget } from '@lumino/widgets';
16import * as React from 'react';
17import { WidgetTracker } from './widgettracker';
18
19/**
20 * Create and show a dialog.
21 *
22 * @param options - The dialog setup options.
23 *
24 * @returns A promise that resolves with whether the dialog was accepted.
25 */
26export function showDialog<T>(
27 options: Partial<Dialog.IOptions<T>> = {}
28): Promise<Dialog.IResult<T>> {
29 const dialog = new Dialog(options);
30 return dialog.launch();
31}
32
33/**
34 * Show an error message dialog.
35 *
36 * @param title - The title of the dialog box.
37 *
38 * @param error - the error to show in the dialog body (either a string
39 * or an object with a string `message` property).
40 */
41export function showErrorMessage(
42 title: string,
43 error: string | Dialog.IError,
44 buttons?: ReadonlyArray<Dialog.IButton>
45): Promise<void> {
46 const trans = Dialog.translator.load('jupyterlab');
47 buttons = buttons ?? [Dialog.okButton({ label: trans.__('Dismiss') })];
48 console.warn('Showing error:', error);
49
50 // Cache promises to prevent multiple copies of identical dialogs showing
51 // to the user.
52 const body = typeof error === 'string' ? error : error.message;
53 const key = title + '----' + body;
54 const promise = Private.errorMessagePromiseCache.get(key);
55 if (promise) {
56 return promise;
57 } else {
58 const dialogPromise = showDialog({
59 title: title,
60 body: body,
61 buttons: buttons
62 }).then(
63 () => {
64 Private.errorMessagePromiseCache.delete(key);
65 },
66 error => {
67 // TODO: Use .finally() above when supported
68 Private.errorMessagePromiseCache.delete(key);
69 throw error;
70 }
71 );
72 Private.errorMessagePromiseCache.set(key, dialogPromise);
73 return dialogPromise;
74 }
75}
76
77/**
78 * A modal dialog widget.
79 */
80export class Dialog<T> extends Widget {
81 /**
82 * Create a dialog panel instance.
83 *
84 * @param options - The dialog setup options.
85 */
86 constructor(options: Partial<Dialog.IOptions<T>> = {}) {
87 const dialogNode = document.createElement('dialog');
88 dialogNode.ariaModal = 'true';
89 super({ node: dialogNode });
90 this.addClass('jp-Dialog');
91 const normalized = Private.handleOptions(options);
92 const renderer = normalized.renderer;
93
94 this._host = normalized.host;
95 this._defaultButton = normalized.defaultButton;
96 this._buttons = normalized.buttons;
97 this._hasClose = normalized.hasClose;
98 this._buttonNodes = this._buttons.map(b => renderer.createButtonNode(b));
99 this._checkboxNode = null;
100 this._lastMouseDownInDialog = false;
101
102 if (normalized.checkbox) {
103 const {
104 label = '',
105 caption = '',
106 checked = false,
107 className = ''
108 } = normalized.checkbox;
109 this._checkboxNode = renderer.createCheckboxNode({
110 label,
111 caption: caption ?? label,
112 checked,
113 className
114 });
115 }
116
117 const layout = (this.layout = new PanelLayout());
118 const content = new Panel();
119 content.addClass('jp-Dialog-content');
120 if (typeof options.body === 'string') {
121 content.addClass('jp-Dialog-content-small');
122 dialogNode.ariaLabel = [normalized.title, options.body].join(' ');
123 }
124 layout.addWidget(content);
125
126 this._body = normalized.body;
127
128 const header = renderer.createHeader(
129 normalized.title,
130 () => this.reject(),
131 options
132 );
133 const body = renderer.createBody(normalized.body);
134 const footer = renderer.createFooter(this._buttonNodes, this._checkboxNode);
135 content.addWidget(header);
136 content.addWidget(body);
137 content.addWidget(footer);
138
139 this._bodyWidget = body;
140 this._primary = this._buttonNodes[this._defaultButton];
141 this._focusNodeSelector = options.focusNodeSelector;
142
143 // Add new dialogs to the tracker.
144 void Dialog.tracker.add(this);
145 }
146
147 /**
148 * A promise that resolves when the Dialog first rendering is done.
149 */
150 get ready(): Promise<void> {
151 return this._ready.promise;
152 }
153
154 /**
155 * Dispose of the resources used by the dialog.
156 */
157 dispose(): void {
158 const promise = this._promise;
159 if (promise) {
160 this._promise = null;
161 promise.reject(void 0);
162 ArrayExt.removeFirstOf(Private.launchQueue, promise.promise);
163 }
164 super.dispose();
165 }
166
167 /**
168 * Launch the dialog as a modal window.
169 *
170 * @returns a promise that resolves with the result of the dialog.
171 */
172 launch(): Promise<Dialog.IResult<T>> {
173 // Return the existing dialog if already open.
174 if (this._promise) {
175 return this._promise.promise;
176 }
177 const promise = (this._promise = new PromiseDelegate<Dialog.IResult<T>>());
178 const promises = Promise.all(Private.launchQueue);
179 Private.launchQueue.push(this._promise.promise);
180 return promises.then(() => {
181 // Do not show Dialog if it was disposed of before it was at the front of the launch queue
182 if (!this._promise) {
183 return Promise.resolve({
184 button: Dialog.cancelButton(),
185 isChecked: null,
186 value: null
187 });
188 }
189 Widget.attach(this, this._host);
190 return promise.promise;
191 });
192 }
193
194 /**
195 * Resolve the current dialog.
196 *
197 * @param index - An optional index to the button to resolve.
198 *
199 * #### Notes
200 * Will default to the defaultIndex.
201 * Will resolve the current `show()` with the button value.
202 * Will be a no-op if the dialog is not shown.
203 */
204 resolve(index?: number): void {
205 if (!this._promise) {
206 return;
207 }
208 if (index === undefined) {
209 index = this._defaultButton;
210 }
211 this._resolve(this._buttons[index]);
212 }
213
214 /**
215 * Reject the current dialog with a default reject value.
216 *
217 * #### Notes
218 * Will be a no-op if the dialog is not shown.
219 */
220 reject(): void {
221 if (!this._promise) {
222 return;
223 }
224 this._resolve(Dialog.cancelButton());
225 }
226
227 /**
228 * Handle the DOM events for the directory listing.
229 *
230 * @param event - The DOM event sent to the widget.
231 *
232 * #### Notes
233 * This method implements the DOM `EventListener` interface and is
234 * called in response to events on the panel's DOM node. It should
235 * not be called directly by user code.
236 */
237 handleEvent(event: Event): void {
238 switch (event.type) {
239 case 'keydown':
240 this._evtKeydown(event as KeyboardEvent);
241 break;
242 case 'mousedown':
243 this._evtMouseDown(event as MouseEvent);
244 break;
245 case 'click':
246 this._evtClick(event as MouseEvent);
247 break;
248 case 'input':
249 this._evtInput(event as InputEvent);
250 break;
251 case 'focus':
252 this._evtFocus(event as FocusEvent);
253 break;
254 case 'contextmenu':
255 event.preventDefault();
256 event.stopPropagation();
257 break;
258 default:
259 break;
260 }
261 }
262
263 /**
264 * A message handler invoked on an `'after-attach'` message.
265 */
266 protected onAfterAttach(msg: Message): void {
267 const node = this.node;
268 node.addEventListener('keydown', this, true);
269 node.addEventListener('contextmenu', this, true);
270 node.addEventListener('click', this, true);
271 document.addEventListener('mousedown', this, true);
272 document.addEventListener('focus', this, true);
273 document.addEventListener('input', this, true);
274 this._first = Private.findFirstFocusable(this.node);
275 this._original = document.activeElement as HTMLElement;
276
277 const setFocus = () => {
278 if (this._focusNodeSelector) {
279 const body = this.node.querySelector('.jp-Dialog-body');
280 const el = body?.querySelector(this._focusNodeSelector);
281
282 if (el) {
283 this._primary = el as HTMLElement;
284 }
285 }
286 this._primary?.focus();
287 this._ready.resolve();
288 };
289
290 if (
291 this._bodyWidget instanceof ReactWidget &&
292 (this._bodyWidget as ReactWidget).renderPromise !== undefined
293 ) {
294 (this._bodyWidget as ReactWidget)
295 .renderPromise!.then(() => {
296 setFocus();
297 })
298 .catch(() => {
299 console.error("Error while loading Dialog's body");
300 });
301 } else {
302 setFocus();
303 }
304 }
305
306 /**
307 * A message handler invoked on an `'after-detach'` message.
308 */
309 protected onAfterDetach(msg: Message): void {
310 const node = this.node;
311 node.removeEventListener('keydown', this, true);
312 node.removeEventListener('contextmenu', this, true);
313 node.removeEventListener('click', this, true);
314 document.removeEventListener('focus', this, true);
315 document.removeEventListener('mousedown', this, true);
316 document.removeEventListener('input', this, true);
317 this._original.focus();
318 }
319
320 /**
321 * A message handler invoked on a `'close-request'` message.
322 */
323 protected onCloseRequest(msg: Message): void {
324 if (this._promise) {
325 this.reject();
326 }
327 super.onCloseRequest(msg);
328 }
329 /**
330 * Handle the `'input'` event for dialog's children.
331 *
332 * @param event - The DOM event sent to the widget
333 */
334 protected _evtInput(_event: InputEvent): void {
335 this._hasValidationErrors = !!this.node.querySelector(':invalid');
336 for (let i = 0; i < this._buttons.length; i++) {
337 if (this._buttons[i].accept) {
338 this._buttonNodes[i].disabled = this._hasValidationErrors;
339 }
340 }
341 }
342
343 /**
344 * Handle the `'click'` event for a dialog button.
345 *
346 * @param event - The DOM event sent to the widget
347 */
348 protected _evtClick(event: MouseEvent): void {
349 const content = this.node.getElementsByClassName(
350 'jp-Dialog-content'
351 )[0] as HTMLElement;
352 if (!content.contains(event.target as HTMLElement)) {
353 event.stopPropagation();
354 event.preventDefault();
355 if (this._hasClose && !this._lastMouseDownInDialog) {
356 this.reject();
357 }
358 return;
359 }
360 for (const buttonNode of this._buttonNodes) {
361 if (buttonNode.contains(event.target as HTMLElement)) {
362 const index = this._buttonNodes.indexOf(buttonNode);
363 this.resolve(index);
364 }
365 }
366 }
367
368 /**
369 * Handle the `'keydown'` event for the widget.
370 *
371 * @param event - The DOM event sent to the widget
372 */
373 protected _evtKeydown(event: KeyboardEvent): void {
374 // Check for escape key
375 switch (event.keyCode) {
376 case 27: // Escape.
377 event.stopPropagation();
378 event.preventDefault();
379 if (this._hasClose) {
380 this.reject();
381 }
382 break;
383 case 37: {
384 // Left arrow
385 const activeEl = document.activeElement;
386
387 if (activeEl instanceof HTMLButtonElement) {
388 let idx = this._buttonNodes.indexOf(activeEl) - 1;
389
390 // Handle a left arrows on the first button
391 if (idx < 0) {
392 idx = this._buttonNodes.length - 1;
393 }
394
395 const node = this._buttonNodes[idx];
396 event.stopPropagation();
397 event.preventDefault();
398 node.focus();
399 }
400 break;
401 }
402 case 39: {
403 // Right arrow
404 const activeEl = document.activeElement;
405
406 if (activeEl instanceof HTMLButtonElement) {
407 let idx = this._buttonNodes.indexOf(activeEl) + 1;
408
409 // Handle a right arrows on the last button
410 if (idx == this._buttons.length) {
411 idx = 0;
412 }
413
414 const node = this._buttonNodes[idx];
415 event.stopPropagation();
416 event.preventDefault();
417 node.focus();
418 }
419 break;
420 }
421 case 9: {
422 // Tab.
423 // Handle a tab on the last button.
424 const node = this._buttonNodes[this._buttons.length - 1];
425 if (document.activeElement === node && !event.shiftKey) {
426 event.stopPropagation();
427 event.preventDefault();
428 this._first.focus();
429 }
430 break;
431 }
432 case 13: {
433 // Enter.
434 event.stopPropagation();
435 event.preventDefault();
436
437 const activeEl = document.activeElement;
438 let index: number | undefined;
439
440 if (activeEl instanceof HTMLButtonElement) {
441 index = this._buttonNodes.indexOf(activeEl);
442 }
443 this.resolve(index);
444 break;
445 }
446 default:
447 break;
448 }
449 }
450
451 /**
452 * Handle the `'focus'` event for the widget.
453 *
454 * @param event - The DOM event sent to the widget
455 */
456 protected _evtFocus(event: FocusEvent): void {
457 const target = event.target as HTMLElement;
458 if (!this.node.contains(target as HTMLElement)) {
459 event.stopPropagation();
460 this._buttonNodes[this._defaultButton]?.focus();
461 }
462 }
463
464 /**
465 * Handle the `'mousedown'` event for the widget.
466 *
467 * @param event - The DOM event sent to the widget
468 */
469 protected _evtMouseDown(event: MouseEvent): void {
470 const content = this.node.getElementsByClassName(
471 'jp-Dialog-content'
472 )[0] as HTMLElement;
473 const target = event.target as HTMLElement;
474 this._lastMouseDownInDialog = content.contains(target as HTMLElement);
475 }
476
477 /**
478 * Resolve a button item.
479 */
480 private _resolve(button: Dialog.IButton): void {
481 if (this._hasValidationErrors && button.accept) {
482 // Do not allow accepting with validation errors
483 return;
484 }
485 // Prevent loopback.
486 const promise = this._promise;
487 if (!promise) {
488 this.dispose();
489 return;
490 }
491 this._promise = null;
492 ArrayExt.removeFirstOf(Private.launchQueue, promise.promise);
493 const body = this._body;
494 let value: T | null = null;
495 if (
496 button.accept &&
497 body instanceof Widget &&
498 typeof body.getValue === 'function'
499 ) {
500 value = body.getValue();
501 }
502 this.dispose();
503 promise.resolve({
504 button,
505 isChecked:
506 this._checkboxNode?.querySelector<HTMLInputElement>('input')?.checked ??
507 null,
508 value
509 });
510 }
511
512 private _hasValidationErrors: boolean = false;
513 private _ready: PromiseDelegate<void> = new PromiseDelegate<void>();
514 private _buttonNodes: ReadonlyArray<HTMLButtonElement>;
515 private _buttons: ReadonlyArray<Dialog.IButton>;
516 private _checkboxNode: HTMLElement | null;
517 private _original: HTMLElement;
518 private _first: HTMLElement;
519 private _primary: HTMLElement;
520 private _promise: PromiseDelegate<Dialog.IResult<T>> | null;
521 private _defaultButton: number;
522 private _host: HTMLElement;
523 private _hasClose: boolean;
524 private _body: Dialog.Body<T>;
525 private _lastMouseDownInDialog: boolean;
526 private _focusNodeSelector: string | undefined = '';
527 private _bodyWidget: Widget;
528}
529
530/**
531 * The namespace for Dialog class statics.
532 */
533export namespace Dialog {
534 /**
535 * Translator object.
536 */
537 export let translator: ITranslator = nullTranslator;
538
539 /**
540 * The body input types.
541 */
542 export type Body<T> = IBodyWidget<T> | React.ReactElement<any> | string;
543
544 /**
545 * The header input types.
546 */
547 export type Header = React.ReactElement<any> | string;
548
549 /**
550 * A widget used as a dialog body.
551 */
552 export interface IBodyWidget<T = string> extends Widget {
553 /**
554 * Get the serialized value of the widget.
555 */
556 getValue?(): T;
557 }
558
559 /**
560 * The options used to make a button item.
561 */
562 export interface IButton {
563 /**
564 * The aria label for the button.
565 */
566 ariaLabel: string;
567 /**
568 * The label for the button.
569 */
570 label: string;
571
572 /**
573 * The icon class for the button.
574 */
575 iconClass: string;
576
577 /**
578 * The icon label for the button.
579 */
580 iconLabel: string;
581
582 /**
583 * The caption for the button.
584 */
585 caption: string;
586
587 /**
588 * The extra class name for the button.
589 */
590 className: string;
591
592 /**
593 * The dialog action to perform when the button is clicked.
594 */
595 accept: boolean;
596
597 /**
598 * The additional dialog actions to perform when the button is clicked.
599 */
600 actions: Array<string>;
601
602 /**
603 * The button display type.
604 */
605 displayType: 'default' | 'warn';
606 }
607
608 /**
609 * The options used to make a checkbox item.
610 */
611 export interface ICheckbox {
612 /**
613 * The label for the checkbox.
614 */
615 label: string;
616
617 /**
618 * The caption for the checkbox.
619 */
620 caption: string;
621
622 /**
623 * The initial checkbox state.
624 */
625 checked: boolean;
626
627 /**
628 * The extra class name for the checkbox.
629 */
630 className: string;
631 }
632
633 /**
634 * Error object interface
635 */
636 export interface IError {
637 /**
638 * Error message
639 */
640 message: string | React.ReactElement<any>;
641 }
642
643 /**
644 * The options used to create a dialog.
645 */
646 export interface IOptions<T> {
647 /**
648 * The top level text for the dialog. Defaults to an empty string.
649 */
650 title: Header;
651
652 /**
653 * The main body element for the dialog or a message to display.
654 * Defaults to an empty string.
655 *
656 * #### Notes
657 * If a widget is given as the body, it will be disposed after the
658 * dialog is resolved. If the widget has a `getValue()` method,
659 * the method will be called prior to disposal and the value
660 * will be provided as part of the dialog result.
661 * A string argument will be used as raw `textContent`.
662 * All `input` and `select` nodes will be wrapped and styled.
663 */
664 body: Body<T>;
665
666 /**
667 * The host element for the dialog. Defaults to `document.body`.
668 */
669 host: HTMLElement;
670
671 /**
672 * The buttons to display. Defaults to cancel and accept buttons.
673 */
674 buttons: ReadonlyArray<IButton>;
675
676 /**
677 * The checkbox to display in the footer. Defaults no checkbox.
678 */
679 checkbox: Partial<ICheckbox> | null;
680
681 /**
682 * The index of the default button. Defaults to the last button.
683 */
684 defaultButton: number;
685
686 /**
687 * A selector for the primary element that should take focus in the dialog.
688 * Defaults to an empty string, causing the [[defaultButton]] to take
689 * focus.
690 */
691 focusNodeSelector: string;
692
693 /**
694 * When "false", disallows user from dismissing the dialog by clicking outside it
695 * or pressing escape. Defaults to "true", which renders a close button.
696 */
697 hasClose: boolean;
698
699 /**
700 * An optional renderer for dialog items. Defaults to a shared
701 * default renderer.
702 */
703 renderer: IRenderer;
704 }
705
706 /**
707 * A dialog renderer.
708 */
709 export interface IRenderer {
710 /**
711 * Create the header of the dialog.
712 *
713 * @param title - The title of the dialog.
714 *
715 * @returns A widget for the dialog header.
716 */
717 createHeader<T>(
718 title: Header,
719 reject: () => void,
720 options: Partial<Dialog.IOptions<T>>
721 ): Widget;
722
723 /**
724 * Create the body of the dialog.
725 *
726 * @param body - The input value for the body.
727 *
728 * @returns A widget for the body.
729 */
730 createBody(body: Body<any>): Widget;
731
732 /**
733 * Create the footer of the dialog.
734 *
735 * @param buttons - The button nodes to add to the footer.
736 * @param checkbox - The checkbox node to add to the footer.
737 *
738 * @returns A widget for the footer.
739 */
740 createFooter(
741 buttons: ReadonlyArray<HTMLElement>,
742 checkbox: HTMLElement | null
743 ): Widget;
744
745 /**
746 * Create a button node for the dialog.
747 *
748 * @param button - The button data.
749 *
750 * @returns A node for the button.
751 */
752 createButtonNode(button: IButton): HTMLButtonElement;
753
754 /**
755 * Create a checkbox node for the dialog.
756 *
757 * @param checkbox - The checkbox data.
758 *
759 * @returns A node for the checkbox.
760 */
761 createCheckboxNode(checkbox: ICheckbox): HTMLElement;
762 }
763
764 /**
765 * The result of a dialog.
766 */
767 export interface IResult<T> {
768 /**
769 * The button that was pressed.
770 */
771 button: IButton;
772
773 /**
774 * State of the dialog checkbox.
775 *
776 * #### Notes
777 * It will be null if no checkbox is defined for the dialog.
778 */
779 isChecked: boolean | null;
780
781 /**
782 * The value retrieved from `.getValue()` if given on the widget.
783 */
784 value: T | null;
785 }
786
787 /**
788 * Create a button item.
789 */
790 export function createButton(value: Partial<IButton>): Readonly<IButton> {
791 value.accept = value.accept !== false;
792 const trans = translator.load('jupyterlab');
793 const defaultLabel = value.accept ? trans.__('Ok') : trans.__('Cancel');
794 return {
795 ariaLabel: value.ariaLabel || value.label || defaultLabel,
796 label: value.label || defaultLabel,
797 iconClass: value.iconClass || '',
798 iconLabel: value.iconLabel || '',
799 caption: value.caption || '',
800 className: value.className || '',
801 accept: value.accept,
802 actions: value.actions || [],
803 displayType: value.displayType || 'default'
804 };
805 }
806
807 /**
808 * Create a reject button.
809 */
810 export function cancelButton(
811 options: Partial<IButton> = {}
812 ): Readonly<IButton> {
813 options.accept = false;
814 return createButton(options);
815 }
816
817 /**
818 * Create an accept button.
819 */
820 export function okButton(options: Partial<IButton> = {}): Readonly<IButton> {
821 options.accept = true;
822 return createButton(options);
823 }
824
825 /**
826 * Create a warn button.
827 */
828 export function warnButton(
829 options: Partial<IButton> = {}
830 ): Readonly<IButton> {
831 options.displayType = 'warn';
832 return createButton(options);
833 }
834
835 /**
836 * Disposes all dialog instances.
837 *
838 * #### Notes
839 * This function should only be used in tests or cases where application state
840 * may be discarded.
841 */
842 export function flush(): void {
843 tracker.forEach(dialog => {
844 dialog.dispose();
845 });
846 }
847
848 /**
849 * The default implementation of a dialog renderer.
850 */
851 export class Renderer {
852 /**
853 * Create the header of the dialog.
854 *
855 * @param title - The title of the dialog.
856 *
857 * @returns A widget for the dialog header.
858 */
859 createHeader<T>(
860 title: Header,
861 reject: () => void = () => {
862 /* empty */
863 },
864 options: Partial<Dialog.IOptions<T>> = {}
865 ): Widget {
866 let header: Widget;
867
868 const handleMouseDown = (event: React.MouseEvent) => {
869 // Fire action only when left button is pressed.
870 if (event.button === 0) {
871 event.preventDefault();
872 reject();
873 }
874 };
875
876 const handleKeyDown = (event: React.KeyboardEvent) => {
877 const { key } = event;
878 if (key === 'Enter' || key === ' ') {
879 reject();
880 }
881 };
882
883 if (typeof title === 'string') {
884 const trans = translator.load('jupyterlab');
885 header = ReactWidget.create(
886 <>
887 {title}
888 {options.hasClose && (
889 <Button
890 className="jp-Dialog-close-button"
891 onMouseDown={handleMouseDown}
892 onKeyDown={handleKeyDown}
893 title={trans.__('Cancel')}
894 minimal
895 >
896 <LabIcon.resolveReact icon={closeIcon} tag="span" />
897 </Button>
898 )}
899 </>
900 );
901 } else {
902 header = ReactWidget.create(title);
903 }
904 header.addClass('jp-Dialog-header');
905 Styling.styleNode(header.node);
906 return header;
907 }
908
909 /**
910 * Create the body of the dialog.
911 *
912 * @param value - The input value for the body.
913 *
914 * @returns A widget for the body.
915 */
916 createBody(value: Body<any>): Widget {
917 const styleReactWidget = (widget: ReactWidget) => {
918 if (widget.renderPromise !== undefined) {
919 widget.renderPromise
920 .then(() => {
921 Styling.styleNode(widget.node);
922 })
923 .catch(() => {
924 console.error("Error while loading Dialog's body");
925 });
926 } else {
927 Styling.styleNode(widget.node);
928 }
929 };
930
931 let body: Widget;
932 if (typeof value === 'string') {
933 body = new Widget({ node: document.createElement('span') });
934 body.node.textContent = value;
935 } else if (value instanceof Widget) {
936 body = value;
937 if (body instanceof ReactWidget) {
938 styleReactWidget(body);
939 } else {
940 Styling.styleNode(body.node);
941 }
942 } else {
943 body = ReactWidget.create(value);
944 // Immediately update the body even though it has not yet attached in
945 // order to trigger a render of the DOM nodes from the React element.
946 MessageLoop.sendMessage(body, Widget.Msg.UpdateRequest);
947 styleReactWidget(body as ReactWidget);
948 }
949 body.addClass('jp-Dialog-body');
950 return body;
951 }
952
953 /**
954 * Create the footer of the dialog.
955 *
956 * @param buttons - The buttons nodes to add to the footer.
957 * @param checkbox - The checkbox node to add to the footer.
958 *
959 * @returns A widget for the footer.
960 */
961 createFooter(
962 buttons: ReadonlyArray<HTMLElement>,
963 checkbox: HTMLElement | null
964 ): Widget {
965 const footer = new Widget();
966
967 footer.addClass('jp-Dialog-footer');
968 if (checkbox) {
969 footer.node.appendChild(checkbox);
970 footer.node.insertAdjacentHTML(
971 'beforeend',
972 '<div class="jp-Dialog-spacer"></div>'
973 );
974 }
975 for (const button of buttons) {
976 footer.node.appendChild(button);
977 }
978 Styling.styleNode(footer.node);
979
980 return footer;
981 }
982
983 /**
984 * Create a button node for the dialog.
985 *
986 * @param button - The button data.
987 *
988 * @returns A node for the button.
989 */
990 createButtonNode(button: IButton): HTMLButtonElement {
991 const e = document.createElement('button');
992 e.className = this.createItemClass(button);
993 e.appendChild(this.renderIcon(button));
994 e.appendChild(this.renderLabel(button));
995 return e;
996 }
997
998 /**
999 * Create a checkbox node for the dialog.
1000 *
1001 * @param checkbox - The checkbox data.
1002 *
1003 * @returns A node for the checkbox.
1004 */
1005 createCheckboxNode(checkbox: ICheckbox): HTMLElement {
1006 const e = document.createElement('label');
1007 e.className = 'jp-Dialog-checkbox';
1008 if (checkbox.className) {
1009 e.classList.add(checkbox.className);
1010 }
1011 e.title = checkbox.caption;
1012 e.textContent = checkbox.label;
1013 const input = document.createElement('input') as HTMLInputElement;
1014 input.type = 'checkbox';
1015 input.checked = !!checkbox.checked;
1016 e.insertAdjacentElement('afterbegin', input);
1017 return e;
1018 }
1019
1020 /**
1021 * Create the class name for the button.
1022 *
1023 * @param data - The data to use for the class name.
1024 *
1025 * @returns The full class name for the button.
1026 */
1027 createItemClass(data: IButton): string {
1028 // Setup the initial class name.
1029 let name = 'jp-Dialog-button';
1030
1031 // Add the other state classes.
1032 if (data.accept) {
1033 name += ' jp-mod-accept';
1034 } else {
1035 name += ' jp-mod-reject';
1036 }
1037 if (data.displayType === 'warn') {
1038 name += ' jp-mod-warn';
1039 }
1040
1041 // Add the extra class.
1042 const extra = data.className;
1043 if (extra) {
1044 name += ` ${extra}`;
1045 }
1046
1047 // Return the complete class name.
1048 return name;
1049 }
1050
1051 /**
1052 * Render an icon element for a dialog item.
1053 *
1054 * @param data - The data to use for rendering the icon.
1055 *
1056 * @returns An HTML element representing the icon.
1057 */
1058 renderIcon(data: IButton): HTMLElement {
1059 const e = document.createElement('div');
1060 e.className = this.createIconClass(data);
1061 e.appendChild(document.createTextNode(data.iconLabel));
1062 return e;
1063 }
1064
1065 /**
1066 * Create the class name for the button icon.
1067 *
1068 * @param data - The data to use for the class name.
1069 *
1070 * @returns The full class name for the item icon.
1071 */
1072 createIconClass(data: IButton): string {
1073 const name = 'jp-Dialog-buttonIcon';
1074 const extra = data.iconClass;
1075 return extra ? `${name} ${extra}` : name;
1076 }
1077
1078 /**
1079 * Render the label element for a button.
1080 *
1081 * @param data - The data to use for rendering the label.
1082 *
1083 * @returns An HTML element representing the item label.
1084 */
1085 renderLabel(data: IButton): HTMLElement {
1086 const e = document.createElement('div');
1087 e.className = 'jp-Dialog-buttonLabel';
1088 e.title = data.caption;
1089 e.ariaLabel = data.ariaLabel;
1090 e.appendChild(document.createTextNode(data.label));
1091 return e;
1092 }
1093 }
1094
1095 /**
1096 * The default renderer instance.
1097 */
1098 export const defaultRenderer = new Renderer();
1099
1100 /**
1101 * The dialog widget tracker.
1102 */
1103 export const tracker = new WidgetTracker<Dialog<any>>({
1104 namespace: '@jupyterlab/apputils:Dialog'
1105 });
1106}
1107
1108/**
1109 * The namespace for module private data.
1110 */
1111namespace Private {
1112 /**
1113 * The queue for launching dialogs.
1114 */
1115 export const launchQueue: Promise<Dialog.IResult<any>>[] = [];
1116
1117 export const errorMessagePromiseCache: Map<string, Promise<void>> = new Map();
1118
1119 /**
1120 * Handle the input options for a dialog.
1121 *
1122 * @param options - The input options.
1123 *
1124 * @returns A new options object with defaults applied.
1125 */
1126 export function handleOptions<T>(
1127 options: Partial<Dialog.IOptions<T>> = {}
1128 ): Dialog.IOptions<T> {
1129 const buttons = options.buttons ?? [
1130 Dialog.cancelButton(),
1131 Dialog.okButton()
1132 ];
1133 return {
1134 title: options.title ?? '',
1135 body: options.body ?? '',
1136 host: options.host ?? document.body,
1137 checkbox: options.checkbox ?? null,
1138 buttons,
1139 defaultButton: options.defaultButton ?? buttons.length - 1,
1140 renderer: options.renderer ?? Dialog.defaultRenderer,
1141 focusNodeSelector: options.focusNodeSelector ?? '',
1142 hasClose: options.hasClose ?? true
1143 };
1144 }
1145
1146 /**
1147 * Find the first focusable item in the dialog.
1148 */
1149 export function findFirstFocusable(node: HTMLElement): HTMLElement {
1150 const candidateSelectors = [
1151 'input',
1152 'select',
1153 'a[href]',
1154 'textarea',
1155 'button',
1156 '[tabindex]'
1157 ].join(',');
1158 return node.querySelectorAll(candidateSelectors)[0] as HTMLElement;
1159 }
1160}