dialog.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  1. // Copyright (c) Jupyter Development Team.
  2. // Distributed under the terms of the Modified BSD License.
  3. import {
  4. Message
  5. } from 'phosphor/lib/core/messaging';
  6. import {
  7. Panel
  8. } from 'phosphor/lib/ui/panel';
  9. import {
  10. Widget
  11. } from 'phosphor/lib/ui/widget';
  12. /**
  13. * The class name added to dialog instances.
  14. */
  15. const DIALOG_CLASS = 'jp-Dialog';
  16. /**
  17. * The class name added to dialog content node.
  18. */
  19. const CONTENT_CLASS = 'jp-Dialog-content';
  20. /**
  21. * The class name added to dialog header node.
  22. */
  23. const HEADER_CLASS = 'jp-Dialog-header';
  24. /**
  25. * The class name added to dialog title node.
  26. */
  27. const TITLE_CLASS = 'jp-Dialog-title';
  28. /**
  29. * The class name added to dialog body node.
  30. */
  31. const BODY_CLASS = 'jp-Dialog-body';
  32. /**
  33. * The class name added to a dialog body content node.
  34. */
  35. const BODY_CONTENT_CLASS = 'jp-Dialog-bodyContent';
  36. /**
  37. * The class name added to a dialog content node.
  38. */
  39. const FOOTER_CLASS = 'jp-Dialog-footer';
  40. /**
  41. * The class name added to a dialog button node.
  42. */
  43. const BUTTON_CLASS = 'jp-Dialog-button';
  44. /**
  45. * The class name added to a dialog button icon node.
  46. */
  47. const BUTTON_ICON_CLASS = 'jp-Dialog-buttonIcon';
  48. /**
  49. * The class name added to a dialog button text node.
  50. */
  51. const BUTTON_TEXT_CLASS = 'jp-Dialog-buttonText';
  52. /*
  53. * The class name added to dialog Confirm buttons.
  54. */
  55. const OK_BUTTON_CLASS = 'jp-Dialog-okButton';
  56. /**
  57. * The class name added to dialog Cancel buttons.
  58. */
  59. const CANCEL_BUTTON_CLASS = 'jp-Dialog-cancelButton';
  60. /**
  61. * The class name added to dialog Warning buttons.
  62. */
  63. const WARNING_BUTTON_CLASS = 'jp-Dialog-warningButton';
  64. /**
  65. * The class name added to dialog input field wrappers.
  66. */
  67. const INPUT_WRAPPER_CLASS = 'jp-Dialog-inputWrapper';
  68. /**
  69. * The class name added to dialog input fields.
  70. */
  71. const INPUT_CLASS = 'jp-Dialog-input';
  72. /**
  73. * The class name added to dialog select wrappers.
  74. */
  75. const SELECT_WRAPPER_CLASS = 'jp-Dialog-selectWrapper';
  76. /**
  77. * The class name added to dialog select nodes.
  78. */
  79. const SELECT_CLASS = 'jp-Dialog-select';
  80. /**
  81. * A button applied to a dialog.
  82. */
  83. export
  84. interface IButtonItem {
  85. /**
  86. * The text for the button.
  87. */
  88. text: string;
  89. /**
  90. * The icon class for the button.
  91. */
  92. icon?: string;
  93. /**
  94. * The extra class name to associate with the button.
  95. */
  96. className?: string;
  97. }
  98. /**
  99. * A default confirmation button.
  100. */
  101. export
  102. const okButton: IButtonItem = {
  103. text: 'OK',
  104. className: OK_BUTTON_CLASS
  105. };
  106. /**
  107. * A default cancel button.
  108. */
  109. export
  110. const cancelButton: IButtonItem = {
  111. text: 'CANCEL',
  112. className: CANCEL_BUTTON_CLASS
  113. };
  114. /**
  115. * A default delete button.
  116. */
  117. export
  118. const deleteButton: IButtonItem = {
  119. text: 'DELETE',
  120. className: WARNING_BUTTON_CLASS
  121. };
  122. /**
  123. * A default warn button.
  124. */
  125. export
  126. const warnButton: IButtonItem = {
  127. text: 'OK',
  128. className: WARNING_BUTTON_CLASS
  129. };
  130. /**
  131. * The options used to create a dialog.
  132. */
  133. export
  134. interface IDialogOptions {
  135. /**
  136. * The tope level text for the dialog (defaults to an empty string).
  137. */
  138. title?: string;
  139. /**
  140. * The main body element for the dialog or a message to display.
  141. *
  142. * #### Notes
  143. * If a `string` is provided, it will be used as the `HTMLContent` of
  144. * a `<span>`. If an `<input>` or `<select>` element is provided,
  145. * they will be styled.
  146. */
  147. body?: Widget | HTMLElement | string;
  148. /**
  149. * The host element for the dialog (defaults to `document.body`).
  150. */
  151. host?: HTMLElement;
  152. /**
  153. * A list of button types to display (defaults to [[okButton]] and
  154. * [[cancelButton]]).
  155. */
  156. buttons?: IButtonItem[];
  157. /**
  158. * The confirmation text for the OK button (defaults to 'OK').
  159. */
  160. okText?: string;
  161. /**
  162. * An additional CSS class to apply to the dialog.
  163. */
  164. dialogClass?: string;
  165. /**
  166. * The primary element or button index that should take focus in the dialog.
  167. *
  168. * The default is the last button.
  169. */
  170. primary?: HTMLElement | number;
  171. }
  172. /**
  173. * Create a dialog and show it.
  174. *
  175. * @param options - The dialog setup options.
  176. *
  177. * @returns A promise that resolves to the button item that was selected.
  178. */
  179. export
  180. function showDialog(options?: IDialogOptions): Promise<IButtonItem> {
  181. options = options || {};
  182. let host = options.host || document.body;
  183. options.host = host;
  184. options.body = options.body || '';
  185. // NOTE: This code assumes only one dialog is shown at the time:
  186. okButton.text = options.okText ? options.okText : 'OK';
  187. options.buttons = options.buttons || [cancelButton, okButton];
  188. if (!options.buttons.length) {
  189. options.buttons = [okButton];
  190. }
  191. if (!(options.body instanceof Widget)) {
  192. options.body = createDialogBody(options.body);
  193. }
  194. return new Promise<IButtonItem>((resolve, reject) => {
  195. let dialog = new Dialog(options, resolve, reject);
  196. Widget.attach(dialog, host);
  197. });
  198. }
  199. /**
  200. * A dialog panel.
  201. */
  202. class Dialog extends Panel {
  203. /**
  204. * Create a dialog panel instance.
  205. *
  206. * @param options - The dialog setup options.
  207. *
  208. * @param resolve - The function that resolves the dialog promise.
  209. *
  210. * @param reject - The function that rejects the dialog promise.
  211. *
  212. * #### Notes
  213. * Currently the dialog resolves with `cancelButton` rather than
  214. * rejecting the dialog promise.
  215. */
  216. constructor(options: IDialogOptions, resolve: (value: IButtonItem) => void, reject?: (error: any) => void) {
  217. super();
  218. if (!(options.body instanceof Widget)) {
  219. throw 'A widget dialog can only be created with a widget as its body.';
  220. }
  221. this.resolve = resolve;
  222. this.reject = reject;
  223. // Create the dialog nodes (except for the buttons).
  224. let content = new Panel();
  225. let header = new Widget({node: document.createElement('div')});
  226. let body = new Panel();
  227. let footer = new Widget({node: document.createElement('div')});
  228. let title = document.createElement('span');
  229. this.addClass(DIALOG_CLASS);
  230. if (options.dialogClass) {
  231. this.addClass(options.dialogClass);
  232. }
  233. content.addClass(CONTENT_CLASS);
  234. header.addClass(HEADER_CLASS);
  235. body.addClass(BODY_CLASS);
  236. footer.addClass(FOOTER_CLASS);
  237. title.className = TITLE_CLASS;
  238. this.addWidget(content);
  239. content.addWidget(header);
  240. content.addWidget(body);
  241. content.addWidget(footer);
  242. header.node.appendChild(title);
  243. // Populate the nodes.
  244. title.textContent = options.title || '';
  245. let child = options.body as Widget;
  246. child.addClass(BODY_CONTENT_CLASS);
  247. body.addWidget(child);
  248. this._buttons = options.buttons.slice();
  249. this._buttonNodes = options.buttons.map(createButton);
  250. this._buttonNodes.map(buttonNode => {
  251. footer.node.appendChild(buttonNode);
  252. });
  253. let primary = options.primary || this.lastButtonNode;
  254. if (typeof primary === 'number') {
  255. primary = this._buttonNodes[primary];
  256. }
  257. this._primary = primary as HTMLElement;
  258. }
  259. /**
  260. * Get the last button node.
  261. */
  262. get lastButtonNode(): HTMLButtonElement {
  263. return this._buttonNodes[this._buttons.length - 1];
  264. }
  265. /**
  266. * Handle the DOM events for the directory listing.
  267. *
  268. * @param event - The DOM event sent to the widget.
  269. *
  270. * #### Notes
  271. * This method implements the DOM `EventListener` interface and is
  272. * called in response to events on the panel's DOM node. It should
  273. * not be called directly by user code.
  274. */
  275. handleEvent(event: Event): void {
  276. switch (event.type) {
  277. case 'keydown':
  278. this._evtKeydown(event as KeyboardEvent);
  279. break;
  280. case 'contextmenu':
  281. this._evtContextMenu(event as MouseEvent);
  282. break;
  283. case 'click':
  284. this._evtClick(event as MouseEvent);
  285. break;
  286. case 'focus':
  287. if (!this.node.contains(event.target as HTMLElement)) {
  288. event.stopPropagation();
  289. this.lastButtonNode.focus();
  290. }
  291. break;
  292. default:
  293. break;
  294. }
  295. }
  296. /**
  297. * Handle an `'after-attach'` message to the widget.
  298. *
  299. * @param msg - The `'after-attach'` message
  300. */
  301. protected onAfterAttach(msg: Message): void {
  302. let node = this.node;
  303. node.addEventListener('keydown', this, true);
  304. node.addEventListener('contextmenu', this, true);
  305. node.addEventListener('click', this, true);
  306. document.addEventListener('focus', this, true);
  307. this._original = document.activeElement as HTMLElement;
  308. this._primary.focus();
  309. }
  310. /**
  311. * Handle a `'before-detach'` message to the widget.
  312. *
  313. * @param msg - The `'after-attach'` message
  314. */
  315. protected onBeforeDetach(msg: Message): void {
  316. let node = this.node;
  317. node.removeEventListener('keydown', this, true);
  318. node.removeEventListener('contextmenu', this, true);
  319. node.removeEventListener('click', this, true);
  320. document.removeEventListener('focus', this, true);
  321. this._original.focus();
  322. }
  323. /**
  324. * Handle the `'click'` event for a dialog button.
  325. *
  326. * @param event - The DOM event sent to the widget
  327. */
  328. protected _evtClick(event: MouseEvent): void {
  329. let content = this.node.getElementsByClassName(CONTENT_CLASS)[0] as HTMLElement;
  330. if (!content.contains(event.target as HTMLElement)) {
  331. this.close();
  332. this.resolve(cancelButton);
  333. event.stopPropagation();
  334. return;
  335. }
  336. for (let buttonNode of this._buttonNodes) {
  337. if (buttonNode.contains(event.target as HTMLElement)) {
  338. this.close();
  339. let button = this._buttons[this._buttonNodes.indexOf(buttonNode)];
  340. this.resolve(button);
  341. }
  342. }
  343. }
  344. /**
  345. * Handle the `'keydown'` event for the widget.
  346. *
  347. * @param event - The DOM event sent to the widget
  348. */
  349. protected _evtKeydown(event: KeyboardEvent): void {
  350. // Check for escape key
  351. switch (event.keyCode) {
  352. case 27:
  353. this.close();
  354. this.resolve(cancelButton);
  355. break;
  356. case 9:
  357. // Handle a tab on the last button.
  358. if (document.activeElement === this.lastButtonNode && !event.shiftKey) {
  359. event.stopPropagation();
  360. event.preventDefault();
  361. if (!this._first) {
  362. this._findFirst();
  363. }
  364. this._first.focus();
  365. }
  366. break;
  367. default:
  368. break;
  369. }
  370. }
  371. /**
  372. * Handle the `'contextmenu'` event for the widget.
  373. *
  374. * @param event - The DOM event sent to the widget
  375. */
  376. protected _evtContextMenu(event: Event): void {
  377. event.preventDefault();
  378. event.stopPropagation();
  379. }
  380. /**
  381. * Find the first focusable item in the dialog.
  382. */
  383. private _findFirst(): void {
  384. let candidateSelectors = [
  385. 'input',
  386. 'select',
  387. 'a[href]',
  388. 'textarea',
  389. 'button',
  390. '[tabindex]',
  391. ].join(',');
  392. this._first = this.node.querySelectorAll(candidateSelectors)[0] as HTMLElement;
  393. }
  394. /**
  395. * The resolution function of the dialog Promise.
  396. */
  397. protected resolve: (value: IButtonItem) => void;
  398. /**
  399. * The rejection function of the dialog Promise.
  400. */
  401. protected reject: (error: any) => void;
  402. private _buttonNodes: HTMLButtonElement[];
  403. private _buttons: IButtonItem[];
  404. private _original: HTMLElement;
  405. private _first: HTMLElement;
  406. private _primary: HTMLElement;
  407. }
  408. /**
  409. * Create a dialog body widget from a non-widget input.
  410. */
  411. function createDialogBody(body: HTMLElement | string): Widget {
  412. let child: HTMLElement;
  413. if (typeof body === 'string') {
  414. child = document.createElement('span');
  415. child.innerHTML = body as string;
  416. } else if (body) {
  417. child = body as HTMLElement;
  418. switch (child.tagName) {
  419. case 'INPUT':
  420. child = wrapInput(child as HTMLInputElement);
  421. break;
  422. case 'SELECT':
  423. child = wrapSelect(child as HTMLSelectElement);
  424. break;
  425. default:
  426. child = styleElements(child);
  427. break;
  428. }
  429. }
  430. child.classList.add(BODY_CONTENT_CLASS);
  431. return new Widget({node: child});
  432. }
  433. /**
  434. * Style the child elements of a parent element.
  435. */
  436. function styleElements(element: HTMLElement): HTMLElement {
  437. for (let i = 0; i < element.children.length; i++) {
  438. let child = element.children[i];
  439. let next = child.nextSibling;
  440. switch (child.tagName) {
  441. case 'INPUT':
  442. child = wrapInput(child as HTMLInputElement);
  443. element.insertBefore(child, next);
  444. break;
  445. case 'SELECT':
  446. child = wrapSelect(child as HTMLSelectElement);
  447. element.insertBefore(child, next);
  448. break;
  449. default:
  450. break;
  451. }
  452. }
  453. return element;
  454. }
  455. /**
  456. * Create a node for a button item.
  457. */
  458. function createButton(item: IButtonItem): HTMLButtonElement {
  459. let button = document.createElement('button') as HTMLButtonElement;
  460. button.className = BUTTON_CLASS;
  461. button.tabIndex = 0;
  462. if (item.className) {
  463. button.classList.add(item.className);
  464. }
  465. let icon = document.createElement('span');
  466. icon.className = BUTTON_ICON_CLASS;
  467. if (item.icon) {
  468. icon.classList.add(item.icon);
  469. }
  470. let text = document.createElement('span');
  471. text.className = BUTTON_TEXT_CLASS;
  472. text.textContent = item.text;
  473. button.appendChild(icon);
  474. button.appendChild(text);
  475. return button;
  476. }
  477. /**
  478. * Wrap and style an input node.
  479. */
  480. function wrapInput(input: HTMLInputElement): HTMLElement {
  481. let wrapper = document.createElement('div');
  482. wrapper.className = INPUT_WRAPPER_CLASS;
  483. wrapper.appendChild(input);
  484. input.classList.add(INPUT_CLASS);
  485. input.tabIndex = 0;
  486. return wrapper;
  487. }
  488. /**
  489. * Wrap and style a select node.
  490. */
  491. function wrapSelect(select: HTMLSelectElement): HTMLElement {
  492. let wrapper = document.createElement('div');
  493. wrapper.className = SELECT_WRAPPER_CLASS;
  494. wrapper.appendChild(select);
  495. select.classList.add(SELECT_CLASS);
  496. select.tabIndex = 0;
  497. return wrapper;
  498. }