dialog.ts 18 KB

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