dialog.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import {
  4. ArrayExt, each, map, toArray
  5. } from '@phosphor/algorithm';
  6. import {
  7. PromiseDelegate
  8. } from '@phosphor/coreutils';
  9. import {
  10. Message
  11. } from '@phosphor/messaging';
  12. import {
  13. VirtualDOM, VirtualElement, h
  14. } from '@phosphor/virtualdom';
  15. import {
  16. PanelLayout, Panel, Widget
  17. } from '@phosphor/widgets';
  18. import {
  19. Styling
  20. } from './styling';
  21. /**
  22. * Create and show a dialog.
  23. *
  24. * @param options - The dialog setup options.
  25. *
  26. * @returns A promise that resolves with whether the dialog was accepted.
  27. */
  28. export
  29. function showDialog<T>(options: Partial<Dialog.IOptions<T>>={}): Promise<Dialog.IResult<T>> {
  30. let dialog = new Dialog(options);
  31. return dialog.launch();
  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. */
  41. export
  42. function showErrorMessage(title: string, error: any): Promise<void> {
  43. console.error(error);
  44. let options = {
  45. title: title,
  46. body: error.message || String(error),
  47. buttons: [Dialog.okButton()],
  48. okText: 'DISMISS'
  49. };
  50. return showDialog(options).then(() => { /* no-op */ });
  51. }
  52. /**
  53. * A modal dialog widget.
  54. */
  55. export
  56. class Dialog<T> extends Widget {
  57. /**
  58. * Create a dialog panel instance.
  59. *
  60. * @param options - The dialog setup options.
  61. */
  62. constructor(options: Partial<Dialog.IOptions<T>>={}) {
  63. super();
  64. this.addClass('jp-Dialog');
  65. let normalized = Private.handleOptions(options);
  66. let renderer = normalized.renderer;
  67. this._host = normalized.host;
  68. this._defaultButton = normalized.defaultButton;
  69. this._buttons = normalized.buttons;
  70. this._buttonNodes = toArray(map(this._buttons, button => {
  71. return renderer.createButtonNode(button);
  72. }));
  73. let layout = this.layout = new PanelLayout();
  74. let content = new Panel();
  75. content.addClass('jp-Dialog-content');
  76. layout.addWidget(content);
  77. this._body = normalized.body;
  78. let header = renderer.createHeader(normalized.title);
  79. let body = renderer.createBody(normalized.body);
  80. let footer = renderer.createFooter(this._buttonNodes);
  81. content.addWidget(header);
  82. content.addWidget(body);
  83. content.addWidget(footer);
  84. this._primary = this._buttonNodes[this._defaultButton];
  85. if (options.focusNodeSelector) {
  86. let el = body.node.querySelector(options.focusNodeSelector);
  87. if (el) {
  88. this._primary = el as HTMLElement;
  89. }
  90. }
  91. }
  92. /**
  93. * Dispose of the resources used by the dialog.
  94. */
  95. dispose(): void {
  96. const promise = this._promise;
  97. if (promise) {
  98. this._promise = null;
  99. promise.reject(void 0);
  100. ArrayExt.removeFirstOf(Private.launchQueue, promise.promise);
  101. }
  102. super.dispose();
  103. }
  104. /**
  105. * Launch the dialog as a modal window.
  106. *
  107. * @returns a promise that resolves with the result of the dialog.
  108. */
  109. launch(): Promise<Dialog.IResult<T>> {
  110. // Return the existing dialog if already open.
  111. if (this._promise) {
  112. return this._promise.promise;
  113. }
  114. const promise = this._promise = new PromiseDelegate<Dialog.IResult<T>>();
  115. let promises = Promise.all(Private.launchQueue);
  116. Private.launchQueue.push(this._promise.promise);
  117. return promises.then(() => {
  118. Widget.attach(this, this._host);
  119. return promise.promise;
  120. });
  121. }
  122. /**
  123. * Resolve the current dialog.
  124. *
  125. * @param index - An optional index to the button to resolve.
  126. *
  127. * #### Notes
  128. * Will default to the defaultIndex.
  129. * Will resolve the current `show()` with the button value.
  130. * Will be a no-op if the dialog is not shown.
  131. */
  132. resolve(index?: number): void {
  133. if (!this._promise) {
  134. return;
  135. }
  136. if (index === undefined) {
  137. index = this._defaultButton;
  138. }
  139. this._resolve(this._buttons[index]);
  140. }
  141. /**
  142. * Reject the current dialog with a default reject value.
  143. *
  144. * #### Notes
  145. * Will be a no-op if the dialog is not shown.
  146. */
  147. reject(): void {
  148. if (!this._promise) {
  149. return;
  150. }
  151. this._resolve(Dialog.cancelButton());
  152. }
  153. /**
  154. * Handle the DOM events for the directory listing.
  155. *
  156. * @param event - The DOM event sent to the widget.
  157. *
  158. * #### Notes
  159. * This method implements the DOM `EventListener` interface and is
  160. * called in response to events on the panel's DOM node. It should
  161. * not be called directly by user code.
  162. */
  163. handleEvent(event: Event): void {
  164. switch (event.type) {
  165. case 'keydown':
  166. this._evtKeydown(event as KeyboardEvent);
  167. break;
  168. case 'click':
  169. this._evtClick(event as MouseEvent);
  170. break;
  171. case 'focus':
  172. this._evtFocus(event as FocusEvent);
  173. break;
  174. case 'contextmenu':
  175. event.preventDefault();
  176. event.stopPropagation();
  177. break;
  178. default:
  179. break;
  180. }
  181. }
  182. /**
  183. * A message handler invoked on a `'before-attach'` message.
  184. */
  185. protected onAfterAttach(msg: Message): void {
  186. let node = this.node;
  187. node.addEventListener('keydown', this, true);
  188. node.addEventListener('contextmenu', this, true);
  189. node.addEventListener('click', this, true);
  190. document.addEventListener('focus', this, true);
  191. this._first = Private.findFirstFocusable(this.node);
  192. this._original = document.activeElement as HTMLElement;
  193. this._primary.focus();
  194. }
  195. /**
  196. * A message handler invoked on a `'after-detach'` message.
  197. */
  198. protected onAfterDetach(msg: Message): void {
  199. let node = this.node;
  200. node.removeEventListener('keydown', this, true);
  201. node.removeEventListener('contextmenu', this, true);
  202. node.removeEventListener('click', this, true);
  203. document.removeEventListener('focus', this, true);
  204. this._original.focus();
  205. }
  206. /**
  207. * A message handler invoked on a `'close-request'` message.
  208. */
  209. protected onCloseRequest(msg: Message): void {
  210. if (this._promise) {
  211. this.reject();
  212. }
  213. super.onCloseRequest(msg);
  214. }
  215. /**
  216. * Handle the `'click'` event for a dialog button.
  217. *
  218. * @param event - The DOM event sent to the widget
  219. */
  220. protected _evtClick(event: MouseEvent): void {
  221. let content = this.node.getElementsByClassName('jp-Dialog-content')[0] as HTMLElement;
  222. if (!content.contains(event.target as HTMLElement)) {
  223. event.stopPropagation();
  224. event.preventDefault();
  225. return;
  226. }
  227. for (let buttonNode of this._buttonNodes) {
  228. if (buttonNode.contains(event.target as HTMLElement)) {
  229. let index = this._buttonNodes.indexOf(buttonNode);
  230. this.resolve(index);
  231. }
  232. }
  233. }
  234. /**
  235. * Handle the `'keydown'` event for the widget.
  236. *
  237. * @param event - The DOM event sent to the widget
  238. */
  239. protected _evtKeydown(event: KeyboardEvent): void {
  240. // Check for escape key
  241. switch (event.keyCode) {
  242. case 27: // Escape.
  243. event.stopPropagation();
  244. event.preventDefault();
  245. this.reject();
  246. break;
  247. case 9: // Tab.
  248. // Handle a tab on the last button.
  249. let node = this._buttonNodes[this._buttons.length - 1];
  250. if (document.activeElement === node && !event.shiftKey) {
  251. event.stopPropagation();
  252. event.preventDefault();
  253. this._first.focus();
  254. }
  255. break;
  256. case 13: // Enter.
  257. event.stopPropagation();
  258. event.preventDefault();
  259. this.resolve();
  260. break;
  261. default:
  262. break;
  263. }
  264. }
  265. /**
  266. * Handle the `'focus'` event for the widget.
  267. *
  268. * @param event - The DOM event sent to the widget
  269. */
  270. protected _evtFocus(event: FocusEvent): void {
  271. let target = event.target as HTMLElement;
  272. if (!this.node.contains(target as HTMLElement)) {
  273. event.stopPropagation();
  274. this._buttonNodes[this._defaultButton].focus();
  275. }
  276. }
  277. /**
  278. * Resolve a button item.
  279. */
  280. private _resolve(button: Dialog.IButton): void {
  281. // Prevent loopback.
  282. const promise = this._promise;
  283. if (!promise) {
  284. this.dispose();
  285. return;
  286. }
  287. this._promise = null;
  288. ArrayExt.removeFirstOf(Private.launchQueue, promise.promise);
  289. let body = this._body;
  290. let value: T | null = null;
  291. if (button.accept && body instanceof Widget && typeof body.getValue === 'function') {
  292. value = body.getValue();
  293. }
  294. this.dispose();
  295. promise.resolve({ button, value });
  296. }
  297. private _buttonNodes: ReadonlyArray<HTMLElement>;
  298. private _buttons: ReadonlyArray<Dialog.IButton>;
  299. private _original: HTMLElement;
  300. private _first: HTMLElement;
  301. private _primary: HTMLElement;
  302. private _promise: PromiseDelegate<Dialog.IResult<T>> | null;
  303. private _defaultButton: number;
  304. private _host: HTMLElement;
  305. private _body: Dialog.BodyType<T>;
  306. }
  307. /**
  308. * The namespace for Dialog class statics.
  309. */
  310. export
  311. namespace Dialog {
  312. /**
  313. * The options used to create a dialog.
  314. */
  315. export
  316. interface IOptions<T> {
  317. /**
  318. * The top level text for the dialog. Defaults to an empty string.
  319. */
  320. title: HeaderType;
  321. /**
  322. * The main body element for the dialog or a message to display.
  323. * Defaults to an empty string.
  324. *
  325. * #### Notes
  326. * If a widget is given as the body, it will be disposed after the
  327. * dialog is resolved. If the widget has a `getValue()` method,
  328. * the method will be called prior to disposal and the value
  329. * will be provided as part of the dialog result.
  330. * A string argument will be used as raw `textContent`.
  331. * All `input` and `select` nodes will be wrapped and styled.
  332. */
  333. body: BodyType<T>;
  334. /**
  335. * The host element for the dialog. Defaults to `document.body`.
  336. */
  337. host: HTMLElement;
  338. /**
  339. * The to buttons to display. Defaults to cancel and accept buttons.
  340. */
  341. buttons: ReadonlyArray<IButton>;
  342. /**
  343. * The index of the default button. Defaults to the last button.
  344. */
  345. defaultButton: number;
  346. /**
  347. * A selector for the primary element that should take focus in the dialog.
  348. * Defaults to an empty string, causing the [[defaultButton]] to take
  349. * focus.
  350. */
  351. focusNodeSelector: string;
  352. /**
  353. * An optional renderer for dialog items. Defaults to a shared
  354. * default renderer.
  355. */
  356. renderer: IRenderer;
  357. }
  358. /**
  359. * The options used to make a button item.
  360. */
  361. export
  362. interface IButton {
  363. /**
  364. * The label for the button.
  365. */
  366. label: string;
  367. /**
  368. * The icon class for the button.
  369. */
  370. iconClass: string;
  371. /**
  372. * The icon label for the button.
  373. */
  374. iconLabel: string;
  375. /**
  376. * The caption for the button.
  377. */
  378. caption: string;
  379. /**
  380. * The extra class name for the button.
  381. */
  382. className: string;
  383. /**
  384. * The dialog action to perform when the button is clicked.
  385. */
  386. accept: boolean;
  387. /**
  388. * The button display type.
  389. */
  390. displayType: 'default' | 'warn';
  391. }
  392. /**
  393. * The options used to create a button.
  394. */
  395. export
  396. type ButtonOptions = Partial<IButton>;
  397. /**
  398. * The header input types.
  399. */
  400. export
  401. type HeaderType = VirtualElement | string;
  402. /**
  403. * The result of a dialog.
  404. */
  405. export
  406. interface IResult<T> {
  407. /**
  408. * The button that was pressed.
  409. */
  410. button: IButton;
  411. /**
  412. * The value retrieved from `.getValue()` if given on the widget.
  413. */
  414. value: T | null;
  415. }
  416. /**
  417. * A widget used as a dialog body.
  418. */
  419. export
  420. interface IBodyWidget<T> extends Widget {
  421. /**
  422. * Get the serialized value of the widget.
  423. */
  424. getValue?(): T;
  425. }
  426. /**
  427. * The body input types.
  428. */
  429. export
  430. type BodyType<T> = IBodyWidget<T> | VirtualElement | string;
  431. /**
  432. * Create an accept button.
  433. */
  434. export
  435. function okButton(options: ButtonOptions = {}): Readonly<IButton> {
  436. options.accept = true;
  437. return createButton(options);
  438. };
  439. /**
  440. * Create a reject button.
  441. */
  442. export
  443. function cancelButton(options: ButtonOptions = {}): Readonly<IButton> {
  444. options.accept = false;
  445. return createButton(options);
  446. };
  447. /**
  448. * Create a warn button.
  449. */
  450. export
  451. function warnButton(options: ButtonOptions = {}): Readonly<IButton> {
  452. options.displayType = 'warn';
  453. return createButton(options);
  454. };
  455. /**
  456. * Create a button item.
  457. */
  458. export
  459. function createButton(value: Dialog.ButtonOptions): Readonly<IButton> {
  460. value.accept = value.accept !== false;
  461. let defaultLabel = value.accept ? 'OK' : 'CANCEL';
  462. return {
  463. label: value.label || defaultLabel,
  464. iconClass: value.iconClass || '',
  465. iconLabel: value.iconLabel || '',
  466. caption: value.caption || '',
  467. className: value.className || '',
  468. accept: value.accept,
  469. displayType: value.displayType || 'default'
  470. };
  471. }
  472. /**
  473. * A dialog renderer.
  474. */
  475. export
  476. interface IRenderer {
  477. /**
  478. * Create the header of the dialog.
  479. *
  480. * @param title - The title of the dialog.
  481. *
  482. * @returns A widget for the dialog header.
  483. */
  484. createHeader(title: HeaderType): Widget;
  485. /**
  486. * Create the body of the dialog.
  487. *
  488. * @param value - The input value for the body.
  489. *
  490. * @returns A widget for the body.
  491. */
  492. createBody(body: BodyType<any>): Widget;
  493. /**
  494. * Create the footer of the dialog.
  495. *
  496. * @param buttons - The button nodes to add to the footer.
  497. *
  498. * @returns A widget for the footer.
  499. */
  500. createFooter(buttons: ReadonlyArray<HTMLElement>): Widget;
  501. /**
  502. * Create a button node for the dialog.
  503. *
  504. * @param button - The button data.
  505. *
  506. * @returns A node for the button.
  507. */
  508. createButtonNode(button: IButton): HTMLElement;
  509. }
  510. /**
  511. * The default implementation of a dialog renderer.
  512. */
  513. export
  514. class Renderer {
  515. /**
  516. * Create the header of the dialog.
  517. *
  518. * @param title - The title of the dialog.
  519. *
  520. * @returns A widget for the dialog header.
  521. */
  522. createHeader(title: HeaderType): Widget {
  523. let header: Widget;
  524. if (typeof title === 'string') {
  525. header = new Widget({ node: document.createElement('span') });
  526. header.node.textContent = title;
  527. } else {
  528. header = new Widget({ node: VirtualDOM.realize(title) });
  529. }
  530. header.addClass('jp-Dialog-header');
  531. Styling.styleNode(header.node);
  532. return header;
  533. }
  534. /**
  535. * Create the body of the dialog.
  536. *
  537. * @param value - The input value for the body.
  538. *
  539. * @returns A widget for the body.
  540. */
  541. createBody(value: BodyType<any>): Widget {
  542. let body: Widget;
  543. if (typeof value === 'string') {
  544. body = new Widget({ node: document.createElement('span') });
  545. body.node.textContent = value;
  546. } else if (value instanceof Widget) {
  547. body = value;
  548. } else {
  549. body = new Widget({ node: VirtualDOM.realize(value) });
  550. }
  551. body.addClass('jp-Dialog-body');
  552. Styling.styleNode(body.node);
  553. return body;
  554. }
  555. /**
  556. * Create the footer of the dialog.
  557. *
  558. * @param buttonNodes - The buttons nodes to add to the footer.
  559. *
  560. * @returns A widget for the footer.
  561. */
  562. createFooter(buttons: ReadonlyArray<HTMLElement>): Widget {
  563. let footer = new Widget();
  564. footer.addClass('jp-Dialog-footer');
  565. each(buttons, button => {
  566. footer.node.appendChild(button);
  567. });
  568. Styling.styleNode(footer.node);
  569. return footer;
  570. }
  571. /**
  572. * Create a button node for the dialog.
  573. *
  574. * @param button - The button data.
  575. *
  576. * @returns A node for the button.
  577. */
  578. createButtonNode(button: IButton): HTMLElement {
  579. let className = this.createItemClass(button);
  580. // We use realize here instead of creating
  581. // nodes with document.createElement as a
  582. // shorthand, and only because this is not
  583. // called often.
  584. return VirtualDOM.realize(
  585. h.button({ className },
  586. this.renderIcon(button),
  587. this.renderLabel(button))
  588. );
  589. }
  590. /**
  591. * Create the class name for the button.
  592. *
  593. * @param data - The data to use for the class name.
  594. *
  595. * @returns The full class name for the button.
  596. */
  597. createItemClass(data: IButton): string {
  598. // Setup the initial class name.
  599. let name = 'jp-Dialog-button';
  600. // Add the other state classes.
  601. if (data.accept) {
  602. name += ' jp-mod-accept';
  603. } else {
  604. name += ' jp-mod-reject';
  605. }
  606. if (data.displayType === 'warn') {
  607. name += ' jp-mod-warn';
  608. }
  609. // Add the extra class.
  610. let extra = data.className;
  611. if (extra) {
  612. name += ` ${extra}`;
  613. }
  614. // Return the complete class name.
  615. return name;
  616. }
  617. /**
  618. * Render an icon element for a dialog item.
  619. *
  620. * @param data - The data to use for rendering the icon.
  621. *
  622. * @returns A virtual element representing the icon.
  623. */
  624. renderIcon(data: IButton): VirtualElement {
  625. return h.div({ className: this.createIconClass(data) },
  626. data.iconLabel);
  627. }
  628. /**
  629. * Create the class name for the button icon.
  630. *
  631. * @param data - The data to use for the class name.
  632. *
  633. * @returns The full class name for the item icon.
  634. */
  635. createIconClass(data: IButton): string {
  636. let name = 'jp-Dialog-buttonIcon';
  637. let extra = data.iconClass;
  638. return extra ? `${name} ${extra}` : name;
  639. }
  640. /**
  641. * Render the label element for a button.
  642. *
  643. * @param data - The data to use for rendering the label.
  644. *
  645. * @returns A virtual element representing the item label.
  646. */
  647. renderLabel(data: IButton): VirtualElement {
  648. let className = 'jp-Dialog-buttonLabel';
  649. let title = data.caption;
  650. return h.div({ className, title }, data.label);
  651. }
  652. }
  653. /**
  654. * The default renderer instance.
  655. */
  656. export
  657. const defaultRenderer = new Renderer();
  658. }
  659. /**
  660. * The namespace for module private data.
  661. */
  662. namespace Private {
  663. /**
  664. * The queue for launching dialogs.
  665. */
  666. export
  667. let launchQueue: Promise<Dialog.IResult<any>>[] = [];
  668. /**
  669. * Handle the input options for a dialog.
  670. *
  671. * @param options - The input options.
  672. *
  673. * @returns A new options object with defaults applied.
  674. */
  675. export
  676. function handleOptions<T>(options: Partial<Dialog.IOptions<T>>={}): Dialog.IOptions<T> {
  677. let buttons = (
  678. options.buttons || [Dialog.cancelButton(), Dialog.okButton()]
  679. );
  680. return {
  681. title: options.title || '',
  682. body: options.body || '',
  683. host: options.host || document.body,
  684. buttons,
  685. defaultButton: options.defaultButton || buttons.length - 1,
  686. renderer: options.renderer || Dialog.defaultRenderer,
  687. focusNodeSelector: options.focusNodeSelector || ''
  688. };
  689. }
  690. /**
  691. * Find the first focusable item in the dialog.
  692. */
  693. export
  694. function findFirstFocusable(node: HTMLElement): HTMLElement {
  695. let candidateSelectors = [
  696. 'input',
  697. 'select',
  698. 'a[href]',
  699. 'textarea',
  700. 'button',
  701. '[tabindex]',
  702. ].join(',');
  703. return node.querySelectorAll(candidateSelectors)[0] as HTMLElement;
  704. }
  705. }