UNPKG

@jupyterlab/apputils

Version:
791 lines 27.4 kB
// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import { 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 { 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 = {}) { 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, error, buttons) { const trans = Dialog.translator.load('jupyterlab'); buttons = buttons !== null && buttons !== void 0 ? 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 = {}) { const dialogNode = document.createElement('dialog'); dialogNode.ariaModal = 'true'; super({ node: dialogNode }); this._hasValidationErrors = false; this._ready = new PromiseDelegate(); this._focusNodeSelector = ''; 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 !== null && caption !== void 0 ? 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() { return this._ready.promise; } /** * Dispose of the resources used by the dialog. */ dispose() { 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() { // 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) { 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() { 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) { switch (event.type) { case 'keydown': this._evtKeydown(event); break; case 'mousedown': this._evtMouseDown(event); break; case 'click': this._evtClick(event); break; case 'input': this._evtInput(event); break; case 'focus': this._evtFocus(event); break; case 'contextmenu': event.preventDefault(); event.stopPropagation(); break; default: break; } } /** * A message handler invoked on an `'after-attach'` message. */ onAfterAttach(msg) { 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); document.addEventListener('input', this, true); this._first = Private.findFirstFocusable(this.node); this._original = document.activeElement; const setFocus = () => { var _a; if (this._focusNodeSelector) { const body = this.node.querySelector('.jp-Dialog-body'); const el = body === null || body === void 0 ? void 0 : body.querySelector(this._focusNodeSelector); if (el) { this._primary = el; } } (_a = this._primary) === null || _a === void 0 ? void 0 : _a.focus(); this._ready.resolve(); }; if (this._bodyWidget instanceof ReactWidget && this._bodyWidget.renderPromise !== undefined) { this._bodyWidget .renderPromise.then(() => { setFocus(); }) .catch(() => { console.error("Error while loading Dialog's body"); }); } else { setFocus(); } } /** * A message handler invoked on an `'after-detach'` message. */ onAfterDetach(msg) { 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); document.removeEventListener('input', this, true); this._original.focus(); } /** * A message handler invoked on a `'close-request'` message. */ onCloseRequest(msg) { if (this._promise) { this.reject(); } super.onCloseRequest(msg); } /** * Handle the `'input'` event for dialog's children. * * @param event - The DOM event sent to the widget */ _evtInput(_event) { this._hasValidationErrors = !!this.node.querySelector(':invalid'); for (let i = 0; i < this._buttons.length; i++) { if (this._buttons[i].accept) { this._buttonNodes[i].disabled = this._hasValidationErrors; } } } /** * Handle the `'click'` event for a dialog button. * * @param event - The DOM event sent to the widget */ _evtClick(event) { const content = this.node.getElementsByClassName('jp-Dialog-content')[0]; if (!content.contains(event.target)) { event.stopPropagation(); event.preventDefault(); if (this._hasClose && !this._lastMouseDownInDialog) { this.reject(); } return; } for (const buttonNode of this._buttonNodes) { if (buttonNode.contains(event.target)) { 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 */ _evtKeydown(event) { // 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) - 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) + 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; if (activeEl instanceof HTMLButtonElement) { index = this._buttonNodes.indexOf(activeEl); } this.resolve(index); break; } default: break; } } /** * Handle the `'focus'` event for the widget. * * @param event - The DOM event sent to the widget */ _evtFocus(event) { var _a; const target = event.target; if (!this.node.contains(target)) { event.stopPropagation(); (_a = this._buttonNodes[this._defaultButton]) === null || _a === void 0 ? void 0 : _a.focus(); } } /** * Handle the `'mousedown'` event for the widget. * * @param event - The DOM event sent to the widget */ _evtMouseDown(event) { const content = this.node.getElementsByClassName('jp-Dialog-content')[0]; const target = event.target; this._lastMouseDownInDialog = content.contains(target); } /** * Resolve a button item. */ _resolve(button) { var _a, _b, _c; if (this._hasValidationErrors && button.accept) { // Do not allow accepting with validation errors return; } // 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 = null; if (button.accept && body instanceof Widget && typeof body.getValue === 'function') { value = body.getValue(); } this.dispose(); promise.resolve({ button, 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, value }); } } /** * The namespace for Dialog class statics. */ (function (Dialog) { /** * Translator object. */ Dialog.translator = nullTranslator; /** * Create a button item. */ function createButton(value) { value.accept = value.accept !== false; const trans = Dialog.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' }; } Dialog.createButton = createButton; /** * Create a reject button. */ function cancelButton(options = {}) { options.accept = false; return createButton(options); } Dialog.cancelButton = cancelButton; /** * Create an accept button. */ function okButton(options = {}) { options.accept = true; return createButton(options); } Dialog.okButton = okButton; /** * Create a warn button. */ function warnButton(options = {}) { options.displayType = 'warn'; return createButton(options); } Dialog.warnButton = warnButton; /** * Disposes all dialog instances. * * #### Notes * This function should only be used in tests or cases where application state * may be discarded. */ function flush() { Dialog.tracker.forEach(dialog => { dialog.dispose(); }); } Dialog.flush = flush; /** * The default implementation of a dialog renderer. */ class Renderer { /** * Create the header of the dialog. * * @param title - The title of the dialog. * * @returns A widget for the dialog header. */ createHeader(title, reject = () => { /* empty */ }, options = {}) { let header; const handleMouseDown = (event) => { // Fire action only when left button is pressed. if (event.button === 0) { event.preventDefault(); reject(); } }; const handleKeyDown = (event) => { const { key } = event; if (key === 'Enter' || key === ' ') { reject(); } }; if (typeof title === 'string') { const trans = Dialog.translator.load('jupyterlab'); header = ReactWidget.create(React.createElement(React.Fragment, null, title, options.hasClose && (React.createElement(Button, { className: "jp-Dialog-close-button", onMouseDown: handleMouseDown, onKeyDown: handleKeyDown, title: trans.__('Cancel'), minimal: true }, React.createElement(LabIcon.resolveReact, { icon: closeIcon, tag: "span" }))))); } 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) { const styleReactWidget = (widget) => { 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; 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); } 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, checkbox) { const footer = new Widget(); footer.addClass('jp-Dialog-footer'); if (checkbox) { footer.node.appendChild(checkbox); footer.node.insertAdjacentHTML('beforeend', '<div class="jp-Dialog-spacer"></div>'); } 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) { 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) { 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'); 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) { // 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) { 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) { 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) { 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; } } Dialog.Renderer = Renderer; /** * The default renderer instance. */ Dialog.defaultRenderer = new Renderer(); /** * The dialog widget tracker. */ Dialog.tracker = new WidgetTracker({ namespace: '@jupyterlab/apputils:Dialog' }); })(Dialog || (Dialog = {})); /** * The namespace for module private data. */ var Private; (function (Private) { /** * The queue for launching dialogs. */ Private.launchQueue = []; Private.errorMessagePromiseCache = new Map(); /** * Handle the input options for a dialog. * * @param options - The input options. * * @returns A new options object with defaults applied. */ function handleOptions(options = {}) { var _a, _b, _c, _d, _e, _f, _g, _h, _j; const buttons = (_a = options.buttons) !== null && _a !== void 0 ? _a : [ Dialog.cancelButton(), Dialog.okButton() ]; return { title: (_b = options.title) !== null && _b !== void 0 ? _b : '', body: (_c = options.body) !== null && _c !== void 0 ? _c : '', host: (_d = options.host) !== null && _d !== void 0 ? _d : document.body, checkbox: (_e = options.checkbox) !== null && _e !== void 0 ? _e : null, buttons, defaultButton: (_f = options.defaultButton) !== null && _f !== void 0 ? _f : buttons.length - 1, renderer: (_g = options.renderer) !== null && _g !== void 0 ? _g : Dialog.defaultRenderer, focusNodeSelector: (_h = options.focusNodeSelector) !== null && _h !== void 0 ? _h : '', hasClose: (_j = options.hasClose) !== null && _j !== void 0 ? _j : true }; } Private.handleOptions = handleOptions; /** * Find the first focusable item in the dialog. */ function findFirstFocusable(node) { const candidateSelectors = [ 'input', 'select', 'a[href]', 'textarea', 'button', '[tabindex]' ].join(','); return node.querySelectorAll(candidateSelectors)[0]; } Private.findFirstFocusable = findFirstFocusable; })(Private || (Private = {})); //# sourceMappingURL=dialog.js.map