UNPKG

30 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 'focus':
249 this._evtFocus(event as FocusEvent);
250 break;
251 case 'contextmenu':
252 event.preventDefault();
253 event.stopPropagation();
254 break;
255 default:
256 break;
257 }
258 }
259
260 /**
261 * A message handler invoked on an `'after-attach'` message.
262 */
263 protected onAfterAttach(msg: Message): void {
264 const node = this.node;
265 node.addEventListener('keydown', this, true);
266 node.addEventListener('contextmenu', this, true);
267 node.addEventListener('click', this, true);
268 document.addEventListener('mousedown', this, true);
269 document.addEventListener('focus', this, true);
270 this._first = Private.findFirstFocusable(this.node);
271 this._original = document.activeElement as HTMLElement;
272
273 const setFocus = () => {
274 if (this._focusNodeSelector) {
275 const body = this.node.querySelector('.jp-Dialog-body');
276 const el = body?.querySelector(this._focusNodeSelector);
277
278 if (el) {
279 this._primary = el as HTMLElement;
280 }
281 }
282 this._primary?.focus();
283 this._ready.resolve();
284 };
285
286 if (
287 this._bodyWidget instanceof ReactWidget &&
288 (this._bodyWidget as ReactWidget).renderPromise !== undefined
289 ) {
290 (this._bodyWidget as ReactWidget)
291 .renderPromise!.then(() => {
292 setFocus();
293 })
294 .catch(() => {
295 console.error("Error while loading Dialog's body");
296 });
297 } else {
298 setFocus();
299 }
300 }
301
302 /**
303 * A message handler invoked on an `'after-detach'` message.
304 */
305 protected onAfterDetach(msg: Message): void {
306 const node = this.node;
307 node.removeEventListener('keydown', this, true);
308 node.removeEventListener('contextmenu', this, true);
309 node.removeEventListener('click', this, true);
310 document.removeEventListener('focus', this, true);
311 document.removeEventListener('mousedown', this, true);
312 this._original.focus();
313 }
314
315 /**
316 * A message handler invoked on a `'close-request'` message.
317 */
318 protected onCloseRequest(msg: Message): void {
319 if (this._promise) {
320 this.reject();
321 }
322 super.onCloseRequest(msg);
323 }
324
325 /**
326 * Handle the `'click'` event for a dialog button.
327 *
328 * @param event - The DOM event sent to the widget
329 */
330 protected _evtClick(event: MouseEvent): void {
331 const content = this.node.getElementsByClassName(
332 'jp-Dialog-content'
333 )[0] as HTMLElement;
334 if (!content.contains(event.target as HTMLElement)) {
335 event.stopPropagation();
336 event.preventDefault();
337 if (this._hasClose && !this._lastMouseDownInDialog) {
338 this.reject();
339 }
340 return;
341 }
342 for (const buttonNode of this._buttonNodes) {
343 if (buttonNode.contains(event.target as HTMLElement)) {
344 const index = this._buttonNodes.indexOf(buttonNode);
345 this.resolve(index);
346 }
347 }
348 }
349
350 /**
351 * Handle the `'keydown'` event for the widget.
352 *
353 * @param event - The DOM event sent to the widget
354 */
355 protected _evtKeydown(event: KeyboardEvent): void {
356 // Check for escape key
357 switch (event.keyCode) {
358 case 27: // Escape.
359 event.stopPropagation();
360 event.preventDefault();
361 if (this._hasClose) {
362 this.reject();
363 }
364 break;
365 case 37: {
366 // Left arrow
367 const activeEl = document.activeElement;
368
369 if (activeEl instanceof HTMLButtonElement) {
370 let idx = this._buttonNodes.indexOf(activeEl as HTMLElement) - 1;
371
372 // Handle a left arrows on the first button
373 if (idx < 0) {
374 idx = this._buttonNodes.length - 1;
375 }
376
377 const node = this._buttonNodes[idx];
378 event.stopPropagation();
379 event.preventDefault();
380 node.focus();
381 }
382 break;
383 }
384 case 39: {
385 // Right arrow
386 const activeEl = document.activeElement;
387
388 if (activeEl instanceof HTMLButtonElement) {
389 let idx = this._buttonNodes.indexOf(activeEl as HTMLElement) + 1;
390
391 // Handle a right arrows on the last button
392 if (idx == this._buttons.length) {
393 idx = 0;
394 }
395
396 const node = this._buttonNodes[idx];
397 event.stopPropagation();
398 event.preventDefault();
399 node.focus();
400 }
401 break;
402 }
403 case 9: {
404 // Tab.
405 // Handle a tab on the last button.
406 const node = this._buttonNodes[this._buttons.length - 1];
407 if (document.activeElement === node && !event.shiftKey) {
408 event.stopPropagation();
409 event.preventDefault();
410 this._first.focus();
411 }
412 break;
413 }
414 case 13: {
415 // Enter.
416 event.stopPropagation();
417 event.preventDefault();
418
419 const activeEl = document.activeElement;
420 let index: number | undefined;
421
422 if (activeEl instanceof HTMLButtonElement) {
423 index = this._buttonNodes.indexOf(activeEl as HTMLElement);
424 }
425 this.resolve(index);
426 break;
427 }
428 default:
429 break;
430 }
431 }
432
433 /**
434 * Handle the `'focus'` event for the widget.
435 *
436 * @param event - The DOM event sent to the widget
437 */
438 protected _evtFocus(event: FocusEvent): void {
439 const target = event.target as HTMLElement;
440 if (!this.node.contains(target as HTMLElement)) {
441 event.stopPropagation();
442 this._buttonNodes[this._defaultButton]?.focus();
443 }
444 }
445
446 /**
447 * Handle the `'mousedown'` event for the widget.
448 *
449 * @param event - The DOM event sent to the widget
450 */
451 protected _evtMouseDown(event: MouseEvent): void {
452 const content = this.node.getElementsByClassName(
453 'jp-Dialog-content'
454 )[0] as HTMLElement;
455 const target = event.target as HTMLElement;
456 this._lastMouseDownInDialog = content.contains(target as HTMLElement);
457 }
458
459 /**
460 * Resolve a button item.
461 */
462 private _resolve(button: Dialog.IButton): void {
463 // Prevent loopback.
464 const promise = this._promise;
465 if (!promise) {
466 this.dispose();
467 return;
468 }
469 this._promise = null;
470 ArrayExt.removeFirstOf(Private.launchQueue, promise.promise);
471 const body = this._body;
472 let value: T | null = null;
473 if (
474 button.accept &&
475 body instanceof Widget &&
476 typeof body.getValue === 'function'
477 ) {
478 value = body.getValue();
479 }
480 this.dispose();
481 promise.resolve({
482 button,
483 isChecked:
484 this._checkboxNode?.querySelector<HTMLInputElement>('input')?.checked ??
485 null,
486 value
487 });
488 }
489
490 private _ready: PromiseDelegate<void> = new PromiseDelegate<void>();
491 private _buttonNodes: ReadonlyArray<HTMLElement>;
492 private _buttons: ReadonlyArray<Dialog.IButton>;
493 private _checkboxNode: HTMLElement | null;
494 private _original: HTMLElement;
495 private _first: HTMLElement;
496 private _primary: HTMLElement;
497 private _promise: PromiseDelegate<Dialog.IResult<T>> | null;
498 private _defaultButton: number;
499 private _host: HTMLElement;
500 private _hasClose: boolean;
501 private _body: Dialog.Body<T>;
502 private _lastMouseDownInDialog: boolean;
503 private _focusNodeSelector: string | undefined = '';
504 private _bodyWidget: Widget;
505}
506
507/**
508 * The namespace for Dialog class statics.
509 */
510export namespace Dialog {
511 /**
512 * Translator object.
513 */
514 export let translator: ITranslator = nullTranslator;
515
516 /**
517 * The body input types.
518 */
519 export type Body<T> = IBodyWidget<T> | React.ReactElement<any> | string;
520
521 /**
522 * The header input types.
523 */
524 export type Header = React.ReactElement<any> | string;
525
526 /**
527 * A widget used as a dialog body.
528 */
529 export interface IBodyWidget<T = string> extends Widget {
530 /**
531 * Get the serialized value of the widget.
532 */
533 getValue?(): T;
534 }
535
536 /**
537 * The options used to make a button item.
538 */
539 export interface IButton {
540 /**
541 * The aria label for the button.
542 */
543 ariaLabel: string;
544 /**
545 * The label for the button.
546 */
547 label: string;
548
549 /**
550 * The icon class for the button.
551 */
552 iconClass: string;
553
554 /**
555 * The icon label for the button.
556 */
557 iconLabel: string;
558
559 /**
560 * The caption for the button.
561 */
562 caption: string;
563
564 /**
565 * The extra class name for the button.
566 */
567 className: string;
568
569 /**
570 * The dialog action to perform when the button is clicked.
571 */
572 accept: boolean;
573
574 /**
575 * The additional dialog actions to perform when the button is clicked.
576 */
577 actions: Array<string>;
578
579 /**
580 * The button display type.
581 */
582 displayType: 'default' | 'warn';
583 }
584
585 /**
586 * The options used to make a checkbox item.
587 */
588 export interface ICheckbox {
589 /**
590 * The label for the checkbox.
591 */
592 label: string;
593
594 /**
595 * The caption for the checkbox.
596 */
597 caption: string;
598
599 /**
600 * The initial checkbox state.
601 */
602 checked: boolean;
603
604 /**
605 * The extra class name for the checkbox.
606 */
607 className: string;
608 }
609
610 /**
611 * Error object interface
612 */
613 export interface IError {
614 /**
615 * Error message
616 */
617 message: string | React.ReactElement<any>;
618 }
619
620 /**
621 * The options used to create a dialog.
622 */
623 export interface IOptions<T> {
624 /**
625 * The top level text for the dialog. Defaults to an empty string.
626 */
627 title: Header;
628
629 /**
630 * The main body element for the dialog or a message to display.
631 * Defaults to an empty string.
632 *
633 * #### Notes
634 * If a widget is given as the body, it will be disposed after the
635 * dialog is resolved. If the widget has a `getValue()` method,
636 * the method will be called prior to disposal and the value
637 * will be provided as part of the dialog result.
638 * A string argument will be used as raw `textContent`.
639 * All `input` and `select` nodes will be wrapped and styled.
640 */
641 body: Body<T>;
642
643 /**
644 * The host element for the dialog. Defaults to `document.body`.
645 */
646 host: HTMLElement;
647
648 /**
649 * The buttons to display. Defaults to cancel and accept buttons.
650 */
651 buttons: ReadonlyArray<IButton>;
652
653 /**
654 * The checkbox to display in the footer. Defaults no checkbox.
655 */
656 checkbox: Partial<ICheckbox> | null;
657
658 /**
659 * The index of the default button. Defaults to the last button.
660 */
661 defaultButton: number;
662
663 /**
664 * A selector for the primary element that should take focus in the dialog.
665 * Defaults to an empty string, causing the [[defaultButton]] to take
666 * focus.
667 */
668 focusNodeSelector: string;
669
670 /**
671 * When "false", disallows user from dismissing the dialog by clicking outside it
672 * or pressing escape. Defaults to "true", which renders a close button.
673 */
674 hasClose: boolean;
675
676 /**
677 * An optional renderer for dialog items. Defaults to a shared
678 * default renderer.
679 */
680 renderer: IRenderer;
681 }
682
683 /**
684 * A dialog renderer.
685 */
686 export interface IRenderer {
687 /**
688 * Create the header of the dialog.
689 *
690 * @param title - The title of the dialog.
691 *
692 * @returns A widget for the dialog header.
693 */
694 createHeader<T>(
695 title: Header,
696 reject: () => void,
697 options: Partial<Dialog.IOptions<T>>
698 ): Widget;
699
700 /**
701 * Create the body of the dialog.
702 *
703 * @param body - The input value for the body.
704 *
705 * @returns A widget for the body.
706 */
707 createBody(body: Body<any>): Widget;
708
709 /**
710 * Create the footer of the dialog.
711 *
712 * @param buttons - The button nodes to add to the footer.
713 * @param checkbox - The checkbox node to add to the footer.
714 *
715 * @returns A widget for the footer.
716 */
717 createFooter(
718 buttons: ReadonlyArray<HTMLElement>,
719 checkbox: HTMLElement | null
720 ): Widget;
721
722 /**
723 * Create a button node for the dialog.
724 *
725 * @param button - The button data.
726 *
727 * @returns A node for the button.
728 */
729 createButtonNode(button: IButton): HTMLElement;
730
731 /**
732 * Create a checkbox node for the dialog.
733 *
734 * @param checkbox - The checkbox data.
735 *
736 * @returns A node for the checkbox.
737 */
738 createCheckboxNode(checkbox: ICheckbox): HTMLElement;
739 }
740
741 /**
742 * The result of a dialog.
743 */
744 export interface IResult<T> {
745 /**
746 * The button that was pressed.
747 */
748 button: IButton;
749
750 /**
751 * State of the dialog checkbox.
752 *
753 * #### Notes
754 * It will be null if no checkbox is defined for the dialog.
755 */
756 isChecked: boolean | null;
757
758 /**
759 * The value retrieved from `.getValue()` if given on the widget.
760 */
761 value: T | null;
762 }
763
764 /**
765 * Create a button item.
766 */
767 export function createButton(value: Partial<IButton>): Readonly<IButton> {
768 value.accept = value.accept !== false;
769 const trans = translator.load('jupyterlab');
770 const defaultLabel = value.accept ? trans.__('Ok') : trans.__('Cancel');
771 return {
772 ariaLabel: value.ariaLabel || value.label || defaultLabel,
773 label: value.label || defaultLabel,
774 iconClass: value.iconClass || '',
775 iconLabel: value.iconLabel || '',
776 caption: value.caption || '',
777 className: value.className || '',
778 accept: value.accept,
779 actions: value.actions || [],
780 displayType: value.displayType || 'default'
781 };
782 }
783
784 /**
785 * Create a reject button.
786 */
787 export function cancelButton(
788 options: Partial<IButton> = {}
789 ): Readonly<IButton> {
790 options.accept = false;
791 return createButton(options);
792 }
793
794 /**
795 * Create an accept button.
796 */
797 export function okButton(options: Partial<IButton> = {}): Readonly<IButton> {
798 options.accept = true;
799 return createButton(options);
800 }
801
802 /**
803 * Create a warn button.
804 */
805 export function warnButton(
806 options: Partial<IButton> = {}
807 ): Readonly<IButton> {
808 options.displayType = 'warn';
809 return createButton(options);
810 }
811
812 /**
813 * Disposes all dialog instances.
814 *
815 * #### Notes
816 * This function should only be used in tests or cases where application state
817 * may be discarded.
818 */
819 export function flush(): void {
820 tracker.forEach(dialog => {
821 dialog.dispose();
822 });
823 }
824
825 /**
826 * The default implementation of a dialog renderer.
827 */
828 export class Renderer {
829 /**
830 * Create the header of the dialog.
831 *
832 * @param title - The title of the dialog.
833 *
834 * @returns A widget for the dialog header.
835 */
836 createHeader<T>(
837 title: Header,
838 reject: () => void = () => {
839 /* empty */
840 },
841 options: Partial<Dialog.IOptions<T>> = {}
842 ): Widget {
843 let header: Widget;
844
845 const handleMouseDown = (event: React.MouseEvent) => {
846 // Fire action only when left button is pressed.
847 if (event.button === 0) {
848 event.preventDefault();
849 reject();
850 }
851 };
852
853 const handleKeyDown = (event: React.KeyboardEvent) => {
854 const { key } = event;
855 if (key === 'Enter' || key === ' ') {
856 reject();
857 }
858 };
859
860 if (typeof title === 'string') {
861 const trans = translator.load('jupyterlab');
862 header = ReactWidget.create(
863 <>
864 {title}
865 {options.hasClose && (
866 <Button
867 className="jp-Dialog-close-button"
868 onMouseDown={handleMouseDown}
869 onKeyDown={handleKeyDown}
870 title={trans.__('Cancel')}
871 minimal
872 >
873 <LabIcon.resolveReact
874 icon={closeIcon}
875 iconClass="jp-Icon"
876 className="jp-ToolbarButtonComponent-icon"
877 tag="span"
878 />
879 </Button>
880 )}
881 </>
882 );
883 } else {
884 header = ReactWidget.create(title);
885 }
886 header.addClass('jp-Dialog-header');
887 Styling.styleNode(header.node);
888 return header;
889 }
890
891 /**
892 * Create the body of the dialog.
893 *
894 * @param value - The input value for the body.
895 *
896 * @returns A widget for the body.
897 */
898 createBody(value: Body<any>): Widget {
899 const styleReactWidget = (widget: ReactWidget) => {
900 if (widget.renderPromise !== undefined) {
901 widget.renderPromise
902 .then(() => {
903 Styling.styleNode(widget.node);
904 })
905 .catch(() => {
906 console.error("Error while loading Dialog's body");
907 });
908 } else {
909 Styling.styleNode(widget.node);
910 }
911 };
912
913 let body: Widget;
914 if (typeof value === 'string') {
915 body = new Widget({ node: document.createElement('span') });
916 body.node.textContent = value;
917 } else if (value instanceof Widget) {
918 body = value;
919 if (body instanceof ReactWidget) {
920 styleReactWidget(body);
921 } else {
922 Styling.styleNode(body.node);
923 }
924 } else {
925 body = ReactWidget.create(value);
926 // Immediately update the body even though it has not yet attached in
927 // order to trigger a render of the DOM nodes from the React element.
928 MessageLoop.sendMessage(body, Widget.Msg.UpdateRequest);
929 styleReactWidget(body as ReactWidget);
930 }
931 body.addClass('jp-Dialog-body');
932 return body;
933 }
934
935 /**
936 * Create the footer of the dialog.
937 *
938 * @param buttons - The buttons nodes to add to the footer.
939 * @param checkbox - The checkbox node to add to the footer.
940 *
941 * @returns A widget for the footer.
942 */
943 createFooter(
944 buttons: ReadonlyArray<HTMLElement>,
945 checkbox: HTMLElement | null
946 ): Widget {
947 const footer = new Widget();
948
949 footer.addClass('jp-Dialog-footer');
950 if (checkbox) {
951 footer.node.appendChild(checkbox);
952 footer.node.insertAdjacentHTML(
953 'beforeend',
954 '<div class="jp-Dialog-spacer"></div>'
955 );
956 }
957 for (const button of buttons) {
958 footer.node.appendChild(button);
959 }
960 Styling.styleNode(footer.node);
961
962 return footer;
963 }
964
965 /**
966 * Create a button node for the dialog.
967 *
968 * @param button - The button data.
969 *
970 * @returns A node for the button.
971 */
972 createButtonNode(button: IButton): HTMLElement {
973 const e = document.createElement('button');
974 e.className = this.createItemClass(button);
975 e.appendChild(this.renderIcon(button));
976 e.appendChild(this.renderLabel(button));
977 return e;
978 }
979
980 /**
981 * Create a checkbox node for the dialog.
982 *
983 * @param checkbox - The checkbox data.
984 *
985 * @returns A node for the checkbox.
986 */
987 createCheckboxNode(checkbox: ICheckbox): HTMLElement {
988 const e = document.createElement('label');
989 e.className = 'jp-Dialog-checkbox';
990 if (checkbox.className) {
991 e.classList.add(checkbox.className);
992 }
993 e.title = checkbox.caption;
994 e.textContent = checkbox.label;
995 const input = document.createElement('input') as HTMLInputElement;
996 input.type = 'checkbox';
997 input.checked = !!checkbox.checked;
998 e.insertAdjacentElement('afterbegin', input);
999 return e;
1000 }
1001
1002 /**
1003 * Create the class name for the button.
1004 *
1005 * @param data - The data to use for the class name.
1006 *
1007 * @returns The full class name for the button.
1008 */
1009 createItemClass(data: IButton): string {
1010 // Setup the initial class name.
1011 let name = 'jp-Dialog-button';
1012
1013 // Add the other state classes.
1014 if (data.accept) {
1015 name += ' jp-mod-accept';
1016 } else {
1017 name += ' jp-mod-reject';
1018 }
1019 if (data.displayType === 'warn') {
1020 name += ' jp-mod-warn';
1021 }
1022
1023 // Add the extra class.
1024 const extra = data.className;
1025 if (extra) {
1026 name += ` ${extra}`;
1027 }
1028
1029 // Return the complete class name.
1030 return name;
1031 }
1032
1033 /**
1034 * Render an icon element for a dialog item.
1035 *
1036 * @param data - The data to use for rendering the icon.
1037 *
1038 * @returns An HTML element representing the icon.
1039 */
1040 renderIcon(data: IButton): HTMLElement {
1041 const e = document.createElement('div');
1042 e.className = this.createIconClass(data);
1043 e.appendChild(document.createTextNode(data.iconLabel));
1044 return e;
1045 }
1046
1047 /**
1048 * Create the class name for the button icon.
1049 *
1050 * @param data - The data to use for the class name.
1051 *
1052 * @returns The full class name for the item icon.
1053 */
1054 createIconClass(data: IButton): string {
1055 const name = 'jp-Dialog-buttonIcon';
1056 const extra = data.iconClass;
1057 return extra ? `${name} ${extra}` : name;
1058 }
1059
1060 /**
1061 * Render the label element for a button.
1062 *
1063 * @param data - The data to use for rendering the label.
1064 *
1065 * @returns An HTML element representing the item label.
1066 */
1067 renderLabel(data: IButton): HTMLElement {
1068 const e = document.createElement('div');
1069 e.className = 'jp-Dialog-buttonLabel';
1070 e.title = data.caption;
1071 e.ariaLabel = data.ariaLabel;
1072 e.appendChild(document.createTextNode(data.label));
1073 return e;
1074 }
1075 }
1076
1077 /**
1078 * The default renderer instance.
1079 */
1080 export const defaultRenderer = new Renderer();
1081
1082 /**
1083 * The dialog widget tracker.
1084 */
1085 export const tracker = new WidgetTracker<Dialog<any>>({
1086 namespace: '@jupyterlab/apputils:Dialog'
1087 });
1088}
1089
1090/**
1091 * The namespace for module private data.
1092 */
1093namespace Private {
1094 /**
1095 * The queue for launching dialogs.
1096 */
1097 export const launchQueue: Promise<Dialog.IResult<any>>[] = [];
1098
1099 export const errorMessagePromiseCache: Map<string, Promise<void>> = new Map();
1100
1101 /**
1102 * Handle the input options for a dialog.
1103 *
1104 * @param options - The input options.
1105 *
1106 * @returns A new options object with defaults applied.
1107 */
1108 export function handleOptions<T>(
1109 options: Partial<Dialog.IOptions<T>> = {}
1110 ): Dialog.IOptions<T> {
1111 const buttons = options.buttons ?? [
1112 Dialog.cancelButton(),
1113 Dialog.okButton()
1114 ];
1115 return {
1116 title: options.title ?? '',
1117 body: options.body ?? '',
1118 host: options.host ?? document.body,
1119 checkbox: options.checkbox ?? null,
1120 buttons,
1121 defaultButton: options.defaultButton ?? buttons.length - 1,
1122 renderer: options.renderer ?? Dialog.defaultRenderer,
1123 focusNodeSelector: options.focusNodeSelector ?? '',
1124 hasClose: options.hasClose ?? true
1125 };
1126 }
1127
1128 /**
1129 * Find the first focusable item in the dialog.
1130 */
1131 export function findFirstFocusable(node: HTMLElement): HTMLElement {
1132 const candidateSelectors = [
1133 'input',
1134 'select',
1135 'a[href]',
1136 'textarea',
1137 'button',
1138 '[tabindex]'
1139 ].join(',');
1140 return node.querySelectorAll(candidateSelectors)[0] as HTMLElement;
1141 }
1142}