dialog.ts 17 KB

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