// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import { ITranslator, nullTranslator } from '@jupyterlab/translation'; import { Button, closeIcon, LabIcon, ReactWidget, Styling } from '@jupyterlab/ui-components'; import { ArrayExt } from '@lumino/algorithm'; import { PromiseDelegate } from '@lumino/coreutils'; import { Message, MessageLoop } from '@lumino/messaging'; import { Panel, PanelLayout, Widget } from '@lumino/widgets'; import * as React from 'react'; import { WidgetTracker } from './widgettracker'; /** * Create and show a dialog. * * @param options - The dialog setup options. * * @returns A promise that resolves with whether the dialog was accepted. */ export function showDialog( options: Partial> = {} ): Promise> { const dialog = new Dialog(options); return dialog.launch(); } /** * Show an error message dialog. * * @param title - The title of the dialog box. * * @param error - the error to show in the dialog body (either a string * or an object with a string `message` property). */ export function showErrorMessage( title: string, error: string | Dialog.IError, buttons?: ReadonlyArray ): Promise { const trans = Dialog.translator.load('jupyterlab'); buttons = buttons ?? [Dialog.okButton({ label: trans.__('Dismiss') })]; console.warn('Showing error:', error); // Cache promises to prevent multiple copies of identical dialogs showing // to the user. const body = typeof error === 'string' ? error : error.message; const key = title + '----' + body; const promise = Private.errorMessagePromiseCache.get(key); if (promise) { return promise; } else { const dialogPromise = showDialog({ title: title, body: body, buttons: buttons }).then( () => { Private.errorMessagePromiseCache.delete(key); }, error => { // TODO: Use .finally() above when supported Private.errorMessagePromiseCache.delete(key); throw error; } ); Private.errorMessagePromiseCache.set(key, dialogPromise); return dialogPromise; } } /** * A modal dialog widget. */ export class Dialog extends Widget { /** * Create a dialog panel instance. * * @param options - The dialog setup options. */ constructor(options: Partial> = {}) { const dialogNode = document.createElement('dialog'); dialogNode.ariaModal = 'true'; super({ node: dialogNode }); this.addClass('jp-Dialog'); const normalized = Private.handleOptions(options); const renderer = normalized.renderer; this._host = normalized.host; this._defaultButton = normalized.defaultButton; this._buttons = normalized.buttons; this._hasClose = normalized.hasClose; this._buttonNodes = this._buttons.map(b => renderer.createButtonNode(b)); this._checkboxNode = null; this._lastMouseDownInDialog = false; if (normalized.checkbox) { const { label = '', caption = '', checked = false, className = '' } = normalized.checkbox; this._checkboxNode = renderer.createCheckboxNode({ label, caption: caption ?? label, checked, className }); } const layout = (this.layout = new PanelLayout()); const content = new Panel(); content.addClass('jp-Dialog-content'); if (typeof options.body === 'string') { content.addClass('jp-Dialog-content-small'); dialogNode.ariaLabel = [normalized.title, options.body].join(' '); } layout.addWidget(content); this._body = normalized.body; const header = renderer.createHeader( normalized.title, () => this.reject(), options ); const body = renderer.createBody(normalized.body); const footer = renderer.createFooter(this._buttonNodes, this._checkboxNode); content.addWidget(header); content.addWidget(body); content.addWidget(footer); this._bodyWidget = body; this._primary = this._buttonNodes[this._defaultButton]; this._focusNodeSelector = options.focusNodeSelector; // Add new dialogs to the tracker. void Dialog.tracker.add(this); } /** * A promise that resolves when the Dialog first rendering is done. */ get ready(): Promise { return this._ready.promise; } /** * Dispose of the resources used by the dialog. */ dispose(): void { const promise = this._promise; if (promise) { this._promise = null; promise.reject(void 0); ArrayExt.removeFirstOf(Private.launchQueue, promise.promise); } super.dispose(); } /** * Launch the dialog as a modal window. * * @returns a promise that resolves with the result of the dialog. */ launch(): Promise> { // Return the existing dialog if already open. if (this._promise) { return this._promise.promise; } const promise = (this._promise = new PromiseDelegate>()); const promises = Promise.all(Private.launchQueue); Private.launchQueue.push(this._promise.promise); return promises.then(() => { // Do not show Dialog if it was disposed of before it was at the front of the launch queue if (!this._promise) { return Promise.resolve({ button: Dialog.cancelButton(), isChecked: null, value: null }); } Widget.attach(this, this._host); return promise.promise; }); } /** * Resolve the current dialog. * * @param index - An optional index to the button to resolve. * * #### Notes * Will default to the defaultIndex. * Will resolve the current `show()` with the button value. * Will be a no-op if the dialog is not shown. */ resolve(index?: number): void { if (!this._promise) { return; } if (index === undefined) { index = this._defaultButton; } this._resolve(this._buttons[index]); } /** * Reject the current dialog with a default reject value. * * #### Notes * Will be a no-op if the dialog is not shown. */ reject(): void { if (!this._promise) { return; } this._resolve(Dialog.cancelButton()); } /** * Handle the DOM events for the directory listing. * * @param event - The DOM event sent to the widget. * * #### Notes * This method implements the DOM `EventListener` interface and is * called in response to events on the panel's DOM node. It should * not be called directly by user code. */ handleEvent(event: Event): void { switch (event.type) { case 'keydown': this._evtKeydown(event as KeyboardEvent); break; case 'mousedown': this._evtMouseDown(event as MouseEvent); break; case 'click': this._evtClick(event as MouseEvent); break; case 'focus': this._evtFocus(event as FocusEvent); break; case 'contextmenu': event.preventDefault(); event.stopPropagation(); break; default: break; } } /** * A message handler invoked on an `'after-attach'` message. */ protected onAfterAttach(msg: Message): void { const node = this.node; node.addEventListener('keydown', this, true); node.addEventListener('contextmenu', this, true); node.addEventListener('click', this, true); document.addEventListener('mousedown', this, true); document.addEventListener('focus', this, true); this._first = Private.findFirstFocusable(this.node); this._original = document.activeElement as HTMLElement; const setFocus = () => { if (this._focusNodeSelector) { const body = this.node.querySelector('.jp-Dialog-body'); const el = body?.querySelector(this._focusNodeSelector); if (el) { this._primary = el as HTMLElement; } } this._primary?.focus(); this._ready.resolve(); }; if ( this._bodyWidget instanceof ReactWidget && (this._bodyWidget as ReactWidget).renderPromise !== undefined ) { (this._bodyWidget as ReactWidget) .renderPromise!.then(() => { setFocus(); }) .catch(() => { console.error("Error while loading Dialog's body"); }); } else { setFocus(); } } /** * A message handler invoked on an `'after-detach'` message. */ protected onAfterDetach(msg: Message): void { const node = this.node; node.removeEventListener('keydown', this, true); node.removeEventListener('contextmenu', this, true); node.removeEventListener('click', this, true); document.removeEventListener('focus', this, true); document.removeEventListener('mousedown', this, true); this._original.focus(); } /** * A message handler invoked on a `'close-request'` message. */ protected onCloseRequest(msg: Message): void { if (this._promise) { this.reject(); } super.onCloseRequest(msg); } /** * Handle the `'click'` event for a dialog button. * * @param event - The DOM event sent to the widget */ protected _evtClick(event: MouseEvent): void { const content = this.node.getElementsByClassName( 'jp-Dialog-content' )[0] as HTMLElement; if (!content.contains(event.target as HTMLElement)) { event.stopPropagation(); event.preventDefault(); if (this._hasClose && !this._lastMouseDownInDialog) { this.reject(); } return; } for (const buttonNode of this._buttonNodes) { if (buttonNode.contains(event.target as HTMLElement)) { const index = this._buttonNodes.indexOf(buttonNode); this.resolve(index); } } } /** * Handle the `'keydown'` event for the widget. * * @param event - The DOM event sent to the widget */ protected _evtKeydown(event: KeyboardEvent): void { // Check for escape key switch (event.keyCode) { case 27: // Escape. event.stopPropagation(); event.preventDefault(); if (this._hasClose) { this.reject(); } break; case 37: { // Left arrow const activeEl = document.activeElement; if (activeEl instanceof HTMLButtonElement) { let idx = this._buttonNodes.indexOf(activeEl as HTMLElement) - 1; // Handle a left arrows on the first button if (idx < 0) { idx = this._buttonNodes.length - 1; } const node = this._buttonNodes[idx]; event.stopPropagation(); event.preventDefault(); node.focus(); } break; } case 39: { // Right arrow const activeEl = document.activeElement; if (activeEl instanceof HTMLButtonElement) { let idx = this._buttonNodes.indexOf(activeEl as HTMLElement) + 1; // Handle a right arrows on the last button if (idx == this._buttons.length) { idx = 0; } const node = this._buttonNodes[idx]; event.stopPropagation(); event.preventDefault(); node.focus(); } break; } case 9: { // Tab. // Handle a tab on the last button. const node = this._buttonNodes[this._buttons.length - 1]; if (document.activeElement === node && !event.shiftKey) { event.stopPropagation(); event.preventDefault(); this._first.focus(); } break; } case 13: { // Enter. event.stopPropagation(); event.preventDefault(); const activeEl = document.activeElement; let index: number | undefined; if (activeEl instanceof HTMLButtonElement) { index = this._buttonNodes.indexOf(activeEl as HTMLElement); } this.resolve(index); break; } default: break; } } /** * Handle the `'focus'` event for the widget. * * @param event - The DOM event sent to the widget */ protected _evtFocus(event: FocusEvent): void { const target = event.target as HTMLElement; if (!this.node.contains(target as HTMLElement)) { event.stopPropagation(); this._buttonNodes[this._defaultButton]?.focus(); } } /** * Handle the `'mousedown'` event for the widget. * * @param event - The DOM event sent to the widget */ protected _evtMouseDown(event: MouseEvent): void { const content = this.node.getElementsByClassName( 'jp-Dialog-content' )[0] as HTMLElement; const target = event.target as HTMLElement; this._lastMouseDownInDialog = content.contains(target as HTMLElement); } /** * Resolve a button item. */ private _resolve(button: Dialog.IButton): void { // Prevent loopback. const promise = this._promise; if (!promise) { this.dispose(); return; } this._promise = null; ArrayExt.removeFirstOf(Private.launchQueue, promise.promise); const body = this._body; let value: T | null = null; if ( button.accept && body instanceof Widget && typeof body.getValue === 'function' ) { value = body.getValue(); } this.dispose(); promise.resolve({ button, isChecked: this._checkboxNode?.querySelector('input')?.checked ?? null, value }); } private _ready: PromiseDelegate = new PromiseDelegate(); private _buttonNodes: ReadonlyArray; private _buttons: ReadonlyArray; private _checkboxNode: HTMLElement | null; private _original: HTMLElement; private _first: HTMLElement; private _primary: HTMLElement; private _promise: PromiseDelegate> | null; private _defaultButton: number; private _host: HTMLElement; private _hasClose: boolean; private _body: Dialog.Body; private _lastMouseDownInDialog: boolean; private _focusNodeSelector: string | undefined = ''; private _bodyWidget: Widget; } /** * The namespace for Dialog class statics. */ export namespace Dialog { /** * Translator object. */ export let translator: ITranslator = nullTranslator; /** * The body input types. */ export type Body = IBodyWidget | React.ReactElement | string; /** * The header input types. */ export type Header = React.ReactElement | string; /** * A widget used as a dialog body. */ export interface IBodyWidget extends Widget { /** * Get the serialized value of the widget. */ getValue?(): T; } /** * The options used to make a button item. */ export interface IButton { /** * The aria label for the button. */ ariaLabel: string; /** * The label for the button. */ label: string; /** * The icon class for the button. */ iconClass: string; /** * The icon label for the button. */ iconLabel: string; /** * The caption for the button. */ caption: string; /** * The extra class name for the button. */ className: string; /** * The dialog action to perform when the button is clicked. */ accept: boolean; /** * The additional dialog actions to perform when the button is clicked. */ actions: Array; /** * The button display type. */ displayType: 'default' | 'warn'; } /** * The options used to make a checkbox item. */ export interface ICheckbox { /** * The label for the checkbox. */ label: string; /** * The caption for the checkbox. */ caption: string; /** * The initial checkbox state. */ checked: boolean; /** * The extra class name for the checkbox. */ className: string; } /** * Error object interface */ export interface IError { /** * Error message */ message: string | React.ReactElement; } /** * The options used to create a dialog. */ export interface IOptions { /** * The top level text for the dialog. Defaults to an empty string. */ title: Header; /** * The main body element for the dialog or a message to display. * Defaults to an empty string. * * #### Notes * If a widget is given as the body, it will be disposed after the * dialog is resolved. If the widget has a `getValue()` method, * the method will be called prior to disposal and the value * will be provided as part of the dialog result. * A string argument will be used as raw `textContent`. * All `input` and `select` nodes will be wrapped and styled. */ body: Body; /** * The host element for the dialog. Defaults to `document.body`. */ host: HTMLElement; /** * The buttons to display. Defaults to cancel and accept buttons. */ buttons: ReadonlyArray; /** * The checkbox to display in the footer. Defaults no checkbox. */ checkbox: Partial | null; /** * The index of the default button. Defaults to the last button. */ defaultButton: number; /** * A selector for the primary element that should take focus in the dialog. * Defaults to an empty string, causing the [[defaultButton]] to take * focus. */ focusNodeSelector: string; /** * When "false", disallows user from dismissing the dialog by clicking outside it * or pressing escape. Defaults to "true", which renders a close button. */ hasClose: boolean; /** * An optional renderer for dialog items. Defaults to a shared * default renderer. */ renderer: IRenderer; } /** * A dialog renderer. */ export interface IRenderer { /** * Create the header of the dialog. * * @param title - The title of the dialog. * * @returns A widget for the dialog header. */ createHeader( title: Header, reject: () => void, options: Partial> ): Widget; /** * Create the body of the dialog. * * @param body - The input value for the body. * * @returns A widget for the body. */ createBody(body: Body): Widget; /** * Create the footer of the dialog. * * @param buttons - The button nodes to add to the footer. * @param checkbox - The checkbox node to add to the footer. * * @returns A widget for the footer. */ createFooter( buttons: ReadonlyArray, checkbox: HTMLElement | null ): Widget; /** * Create a button node for the dialog. * * @param button - The button data. * * @returns A node for the button. */ createButtonNode(button: IButton): HTMLElement; /** * Create a checkbox node for the dialog. * * @param checkbox - The checkbox data. * * @returns A node for the checkbox. */ createCheckboxNode(checkbox: ICheckbox): HTMLElement; } /** * The result of a dialog. */ export interface IResult { /** * The button that was pressed. */ button: IButton; /** * State of the dialog checkbox. * * #### Notes * It will be null if no checkbox is defined for the dialog. */ isChecked: boolean | null; /** * The value retrieved from `.getValue()` if given on the widget. */ value: T | null; } /** * Create a button item. */ export function createButton(value: Partial): Readonly { value.accept = value.accept !== false; const trans = translator.load('jupyterlab'); const defaultLabel = value.accept ? trans.__('Ok') : trans.__('Cancel'); return { ariaLabel: value.ariaLabel || value.label || defaultLabel, label: value.label || defaultLabel, iconClass: value.iconClass || '', iconLabel: value.iconLabel || '', caption: value.caption || '', className: value.className || '', accept: value.accept, actions: value.actions || [], displayType: value.displayType || 'default' }; } /** * Create a reject button. */ export function cancelButton( options: Partial = {} ): Readonly { options.accept = false; return createButton(options); } /** * Create an accept button. */ export function okButton(options: Partial = {}): Readonly { options.accept = true; return createButton(options); } /** * Create a warn button. */ export function warnButton( options: Partial = {} ): Readonly { options.displayType = 'warn'; return createButton(options); } /** * Disposes all dialog instances. * * #### Notes * This function should only be used in tests or cases where application state * may be discarded. */ export function flush(): void { tracker.forEach(dialog => { dialog.dispose(); }); } /** * The default implementation of a dialog renderer. */ export class Renderer { /** * Create the header of the dialog. * * @param title - The title of the dialog. * * @returns A widget for the dialog header. */ createHeader( title: Header, reject: () => void = () => { /* empty */ }, options: Partial> = {} ): Widget { let header: Widget; const handleMouseDown = (event: React.MouseEvent) => { // Fire action only when left button is pressed. if (event.button === 0) { event.preventDefault(); reject(); } }; const handleKeyDown = (event: React.KeyboardEvent) => { const { key } = event; if (key === 'Enter' || key === ' ') { reject(); } }; if (typeof title === 'string') { const trans = translator.load('jupyterlab'); header = ReactWidget.create( <> {title} {options.hasClose && ( )} ); } else { header = ReactWidget.create(title); } header.addClass('jp-Dialog-header'); Styling.styleNode(header.node); return header; } /** * Create the body of the dialog. * * @param value - The input value for the body. * * @returns A widget for the body. */ createBody(value: Body): Widget { const styleReactWidget = (widget: ReactWidget) => { if (widget.renderPromise !== undefined) { widget.renderPromise .then(() => { Styling.styleNode(widget.node); }) .catch(() => { console.error("Error while loading Dialog's body"); }); } else { Styling.styleNode(widget.node); } }; let body: Widget; if (typeof value === 'string') { body = new Widget({ node: document.createElement('span') }); body.node.textContent = value; } else if (value instanceof Widget) { body = value; if (body instanceof ReactWidget) { styleReactWidget(body); } else { Styling.styleNode(body.node); } } else { body = ReactWidget.create(value); // Immediately update the body even though it has not yet attached in // order to trigger a render of the DOM nodes from the React element. MessageLoop.sendMessage(body, Widget.Msg.UpdateRequest); styleReactWidget(body as ReactWidget); } body.addClass('jp-Dialog-body'); return body; } /** * Create the footer of the dialog. * * @param buttons - The buttons nodes to add to the footer. * @param checkbox - The checkbox node to add to the footer. * * @returns A widget for the footer. */ createFooter( buttons: ReadonlyArray, checkbox: HTMLElement | null ): Widget { const footer = new Widget(); footer.addClass('jp-Dialog-footer'); if (checkbox) { footer.node.appendChild(checkbox); footer.node.insertAdjacentHTML( 'beforeend', '' ); } for (const button of buttons) { footer.node.appendChild(button); } Styling.styleNode(footer.node); return footer; } /** * Create a button node for the dialog. * * @param button - The button data. * * @returns A node for the button. */ createButtonNode(button: IButton): HTMLElement { const e = document.createElement('button'); e.className = this.createItemClass(button); e.appendChild(this.renderIcon(button)); e.appendChild(this.renderLabel(button)); return e; } /** * Create a checkbox node for the dialog. * * @param checkbox - The checkbox data. * * @returns A node for the checkbox. */ createCheckboxNode(checkbox: ICheckbox): HTMLElement { const e = document.createElement('label'); e.className = 'jp-Dialog-checkbox'; if (checkbox.className) { e.classList.add(checkbox.className); } e.title = checkbox.caption; e.textContent = checkbox.label; const input = document.createElement('input') as HTMLInputElement; input.type = 'checkbox'; input.checked = !!checkbox.checked; e.insertAdjacentElement('afterbegin', input); return e; } /** * Create the class name for the button. * * @param data - The data to use for the class name. * * @returns The full class name for the button. */ createItemClass(data: IButton): string { // Setup the initial class name. let name = 'jp-Dialog-button'; // Add the other state classes. if (data.accept) { name += ' jp-mod-accept'; } else { name += ' jp-mod-reject'; } if (data.displayType === 'warn') { name += ' jp-mod-warn'; } // Add the extra class. const extra = data.className; if (extra) { name += ` ${extra}`; } // Return the complete class name. return name; } /** * Render an icon element for a dialog item. * * @param data - The data to use for rendering the icon. * * @returns An HTML element representing the icon. */ renderIcon(data: IButton): HTMLElement { const e = document.createElement('div'); e.className = this.createIconClass(data); e.appendChild(document.createTextNode(data.iconLabel)); return e; } /** * Create the class name for the button icon. * * @param data - The data to use for the class name. * * @returns The full class name for the item icon. */ createIconClass(data: IButton): string { const name = 'jp-Dialog-buttonIcon'; const extra = data.iconClass; return extra ? `${name} ${extra}` : name; } /** * Render the label element for a button. * * @param data - The data to use for rendering the label. * * @returns An HTML element representing the item label. */ renderLabel(data: IButton): HTMLElement { const e = document.createElement('div'); e.className = 'jp-Dialog-buttonLabel'; e.title = data.caption; e.ariaLabel = data.ariaLabel; e.appendChild(document.createTextNode(data.label)); return e; } } /** * The default renderer instance. */ export const defaultRenderer = new Renderer(); /** * The dialog widget tracker. */ export const tracker = new WidgetTracker>({ namespace: '@jupyterlab/apputils:Dialog' }); } /** * The namespace for module private data. */ namespace Private { /** * The queue for launching dialogs. */ export const launchQueue: Promise>[] = []; export const errorMessagePromiseCache: Map> = new Map(); /** * Handle the input options for a dialog. * * @param options - The input options. * * @returns A new options object with defaults applied. */ export function handleOptions( options: Partial> = {} ): Dialog.IOptions { const buttons = options.buttons ?? [ Dialog.cancelButton(), Dialog.okButton() ]; return { title: options.title ?? '', body: options.body ?? '', host: options.host ?? document.body, checkbox: options.checkbox ?? null, buttons, defaultButton: options.defaultButton ?? buttons.length - 1, renderer: options.renderer ?? Dialog.defaultRenderer, focusNodeSelector: options.focusNodeSelector ?? '', hasClose: options.hasClose ?? true }; } /** * Find the first focusable item in the dialog. */ export function findFirstFocusable(node: HTMLElement): HTMLElement { const candidateSelectors = [ 'input', 'select', 'a[href]', 'textarea', 'button', '[tabindex]' ].join(','); return node.querySelectorAll(candidateSelectors)[0] as HTMLElement; } }