theme-plugin-editor.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991
  1. /* eslint no-magic-numbers: ["error", { "ignore": [-1, 0, 1] }] */
  2. if ( ! window.wp ) {
  3. window.wp = {};
  4. }
  5. wp.themePluginEditor = (function( $ ) {
  6. 'use strict';
  7. var component, TreeLinks;
  8. component = {
  9. l10n: {
  10. lintError: {
  11. singular: '',
  12. plural: ''
  13. },
  14. saveAlert: '',
  15. saveError: ''
  16. },
  17. codeEditor: {},
  18. instance: null,
  19. noticeElements: {},
  20. dirty: false,
  21. lintErrors: []
  22. };
  23. /**
  24. * Initialize component.
  25. *
  26. * @since 4.9.0
  27. *
  28. * @param {jQuery} form - Form element.
  29. * @param {object} settings - Settings.
  30. * @param {object|boolean} settings.codeEditor - Code editor settings (or `false` if syntax highlighting is disabled).
  31. * @returns {void}
  32. */
  33. component.init = function init( form, settings ) {
  34. component.form = form;
  35. if ( settings ) {
  36. $.extend( component, settings );
  37. }
  38. component.noticeTemplate = wp.template( 'wp-file-editor-notice' );
  39. component.noticesContainer = component.form.find( '.editor-notices' );
  40. component.submitButton = component.form.find( ':input[name=submit]' );
  41. component.spinner = component.form.find( '.submit .spinner' );
  42. component.form.on( 'submit', component.submit );
  43. component.textarea = component.form.find( '#newcontent' );
  44. component.textarea.on( 'change', component.onChange );
  45. component.warning = $( '.file-editor-warning' );
  46. if ( component.warning.length > 0 ) {
  47. component.showWarning();
  48. }
  49. if ( false !== component.codeEditor ) {
  50. /*
  51. * Defer adding notices until after DOM ready as workaround for WP Admin injecting
  52. * its own managed dismiss buttons and also to prevent the editor from showing a notice
  53. * when the file had linting errors to begin with.
  54. */
  55. _.defer( function() {
  56. component.initCodeEditor();
  57. } );
  58. }
  59. $( component.initFileBrowser );
  60. $( window ).on( 'beforeunload', function() {
  61. if ( component.dirty ) {
  62. return component.l10n.saveAlert;
  63. }
  64. return undefined;
  65. } );
  66. };
  67. /**
  68. * Set up and display the warning modal.
  69. *
  70. * @since 4.9.0
  71. * @returns {void}
  72. */
  73. component.showWarning = function() {
  74. // Get the text within the modal.
  75. var rawMessage = component.warning.find( '.file-editor-warning-message' ).text();
  76. // Hide all the #wpwrap content from assistive technologies.
  77. $( '#wpwrap' ).attr( 'aria-hidden', 'true' );
  78. // Detach the warning modal from its position and append it to the body.
  79. $( document.body )
  80. .addClass( 'modal-open' )
  81. .append( component.warning.detach() );
  82. // Reveal the modal and set focus on the go back button.
  83. component.warning
  84. .removeClass( 'hidden' )
  85. .find( '.file-editor-warning-go-back' ).focus();
  86. // Get the links and buttons within the modal.
  87. component.warningTabbables = component.warning.find( 'a, button' );
  88. // Attach event handlers.
  89. component.warningTabbables.on( 'keydown', component.constrainTabbing );
  90. component.warning.on( 'click', '.file-editor-warning-dismiss', component.dismissWarning );
  91. // Make screen readers announce the warning message after a short delay (necessary for some screen readers).
  92. setTimeout( function() {
  93. wp.a11y.speak( wp.sanitize.stripTags( rawMessage.replace( /\s+/g, ' ' ) ), 'assertive' );
  94. }, 1000 );
  95. };
  96. /**
  97. * Constrain tabbing within the warning modal.
  98. *
  99. * @since 4.9.0
  100. * @param {object} event jQuery event object.
  101. * @returns {void}
  102. */
  103. component.constrainTabbing = function( event ) {
  104. var firstTabbable, lastTabbable;
  105. if ( 9 !== event.which ) {
  106. return;
  107. }
  108. firstTabbable = component.warningTabbables.first()[0];
  109. lastTabbable = component.warningTabbables.last()[0];
  110. if ( lastTabbable === event.target && ! event.shiftKey ) {
  111. firstTabbable.focus();
  112. event.preventDefault();
  113. } else if ( firstTabbable === event.target && event.shiftKey ) {
  114. lastTabbable.focus();
  115. event.preventDefault();
  116. }
  117. };
  118. /**
  119. * Dismiss the warning modal.
  120. *
  121. * @since 4.9.0
  122. * @returns {void}
  123. */
  124. component.dismissWarning = function() {
  125. wp.ajax.post( 'dismiss-wp-pointer', {
  126. pointer: component.themeOrPlugin + '_editor_notice'
  127. });
  128. // Hide modal.
  129. component.warning.remove();
  130. $( '#wpwrap' ).removeAttr( 'aria-hidden' );
  131. $( 'body' ).removeClass( 'modal-open' );
  132. };
  133. /**
  134. * Callback for when a change happens.
  135. *
  136. * @since 4.9.0
  137. * @returns {void}
  138. */
  139. component.onChange = function() {
  140. component.dirty = true;
  141. component.removeNotice( 'file_saved' );
  142. };
  143. /**
  144. * Submit file via Ajax.
  145. *
  146. * @since 4.9.0
  147. * @param {jQuery.Event} event - Event.
  148. * @returns {void}
  149. */
  150. component.submit = function( event ) {
  151. var data = {}, request;
  152. event.preventDefault(); // Prevent form submission in favor of Ajax below.
  153. $.each( component.form.serializeArray(), function() {
  154. data[ this.name ] = this.value;
  155. } );
  156. // Use value from codemirror if present.
  157. if ( component.instance ) {
  158. data.newcontent = component.instance.codemirror.getValue();
  159. }
  160. if ( component.isSaving ) {
  161. return;
  162. }
  163. // Scroll ot the line that has the error.
  164. if ( component.lintErrors.length ) {
  165. component.instance.codemirror.setCursor( component.lintErrors[0].from.line );
  166. return;
  167. }
  168. component.isSaving = true;
  169. component.textarea.prop( 'readonly', true );
  170. if ( component.instance ) {
  171. component.instance.codemirror.setOption( 'readOnly', true );
  172. }
  173. component.spinner.addClass( 'is-active' );
  174. request = wp.ajax.post( 'edit-theme-plugin-file', data );
  175. // Remove previous save notice before saving.
  176. if ( component.lastSaveNoticeCode ) {
  177. component.removeNotice( component.lastSaveNoticeCode );
  178. }
  179. request.done( function( response ) {
  180. component.lastSaveNoticeCode = 'file_saved';
  181. component.addNotice({
  182. code: component.lastSaveNoticeCode,
  183. type: 'success',
  184. message: response.message,
  185. dismissible: true
  186. });
  187. component.dirty = false;
  188. } );
  189. request.fail( function( response ) {
  190. var notice = $.extend(
  191. {
  192. code: 'save_error',
  193. message: component.l10n.saveError
  194. },
  195. response,
  196. {
  197. type: 'error',
  198. dismissible: true
  199. }
  200. );
  201. component.lastSaveNoticeCode = notice.code;
  202. component.addNotice( notice );
  203. } );
  204. request.always( function() {
  205. component.spinner.removeClass( 'is-active' );
  206. component.isSaving = false;
  207. component.textarea.prop( 'readonly', false );
  208. if ( component.instance ) {
  209. component.instance.codemirror.setOption( 'readOnly', false );
  210. }
  211. } );
  212. };
  213. /**
  214. * Add notice.
  215. *
  216. * @since 4.9.0
  217. *
  218. * @param {object} notice - Notice.
  219. * @param {string} notice.code - Code.
  220. * @param {string} notice.type - Type.
  221. * @param {string} notice.message - Message.
  222. * @param {boolean} [notice.dismissible=false] - Dismissible.
  223. * @param {Function} [notice.onDismiss] - Callback for when a user dismisses the notice.
  224. * @returns {jQuery} Notice element.
  225. */
  226. component.addNotice = function( notice ) {
  227. var noticeElement;
  228. if ( ! notice.code ) {
  229. throw new Error( 'Missing code.' );
  230. }
  231. // Only let one notice of a given type be displayed at a time.
  232. component.removeNotice( notice.code );
  233. noticeElement = $( component.noticeTemplate( notice ) );
  234. noticeElement.hide();
  235. noticeElement.find( '.notice-dismiss' ).on( 'click', function() {
  236. component.removeNotice( notice.code );
  237. if ( notice.onDismiss ) {
  238. notice.onDismiss( notice );
  239. }
  240. } );
  241. wp.a11y.speak( notice.message );
  242. component.noticesContainer.append( noticeElement );
  243. noticeElement.slideDown( 'fast' );
  244. component.noticeElements[ notice.code ] = noticeElement;
  245. return noticeElement;
  246. };
  247. /**
  248. * Remove notice.
  249. *
  250. * @since 4.9.0
  251. *
  252. * @param {string} code - Notice code.
  253. * @returns {boolean} Whether a notice was removed.
  254. */
  255. component.removeNotice = function( code ) {
  256. if ( component.noticeElements[ code ] ) {
  257. component.noticeElements[ code ].slideUp( 'fast', function() {
  258. $( this ).remove();
  259. } );
  260. delete component.noticeElements[ code ];
  261. return true;
  262. }
  263. return false;
  264. };
  265. /**
  266. * Initialize code editor.
  267. *
  268. * @since 4.9.0
  269. * @returns {void}
  270. */
  271. component.initCodeEditor = function initCodeEditor() {
  272. var codeEditorSettings, editor;
  273. codeEditorSettings = $.extend( {}, component.codeEditor );
  274. /**
  275. * Handle tabbing to the field before the editor.
  276. *
  277. * @since 4.9.0
  278. *
  279. * @returns {void}
  280. */
  281. codeEditorSettings.onTabPrevious = function() {
  282. $( '#templateside' ).find( ':tabbable' ).last().focus();
  283. };
  284. /**
  285. * Handle tabbing to the field after the editor.
  286. *
  287. * @since 4.9.0
  288. *
  289. * @returns {void}
  290. */
  291. codeEditorSettings.onTabNext = function() {
  292. $( '#template' ).find( ':tabbable:not(.CodeMirror-code)' ).first().focus();
  293. };
  294. /**
  295. * Handle change to the linting errors.
  296. *
  297. * @since 4.9.0
  298. *
  299. * @param {Array} errors - List of linting errors.
  300. * @returns {void}
  301. */
  302. codeEditorSettings.onChangeLintingErrors = function( errors ) {
  303. component.lintErrors = errors;
  304. // Only disable the button in onUpdateErrorNotice when there are errors so users can still feel they can click the button.
  305. if ( 0 === errors.length ) {
  306. component.submitButton.toggleClass( 'disabled', false );
  307. }
  308. };
  309. /**
  310. * Update error notice.
  311. *
  312. * @since 4.9.0
  313. *
  314. * @param {Array} errorAnnotations - Error annotations.
  315. * @returns {void}
  316. */
  317. codeEditorSettings.onUpdateErrorNotice = function onUpdateErrorNotice( errorAnnotations ) {
  318. var message, noticeElement;
  319. component.submitButton.toggleClass( 'disabled', errorAnnotations.length > 0 );
  320. if ( 0 !== errorAnnotations.length ) {
  321. if ( 1 === errorAnnotations.length ) {
  322. message = component.l10n.lintError.singular.replace( '%d', '1' );
  323. } else {
  324. message = component.l10n.lintError.plural.replace( '%d', String( errorAnnotations.length ) );
  325. }
  326. noticeElement = component.addNotice({
  327. code: 'lint_errors',
  328. type: 'error',
  329. message: message,
  330. dismissible: false
  331. });
  332. noticeElement.find( 'input[type=checkbox]' ).on( 'click', function() {
  333. codeEditorSettings.onChangeLintingErrors( [] );
  334. component.removeNotice( 'lint_errors' );
  335. } );
  336. } else {
  337. component.removeNotice( 'lint_errors' );
  338. }
  339. };
  340. editor = wp.codeEditor.initialize( $( '#newcontent' ), codeEditorSettings );
  341. editor.codemirror.on( 'change', component.onChange );
  342. // Improve the editor accessibility.
  343. $( editor.codemirror.display.lineDiv )
  344. .attr({
  345. role: 'textbox',
  346. 'aria-multiline': 'true',
  347. 'aria-labelledby': 'theme-plugin-editor-label',
  348. 'aria-describedby': 'editor-keyboard-trap-help-1 editor-keyboard-trap-help-2 editor-keyboard-trap-help-3 editor-keyboard-trap-help-4'
  349. });
  350. // Focus the editor when clicking on its label.
  351. $( '#theme-plugin-editor-label' ).on( 'click', function() {
  352. editor.codemirror.focus();
  353. });
  354. component.instance = editor;
  355. };
  356. /**
  357. * Initialization of the file browser's folder states.
  358. *
  359. * @since 4.9.0
  360. * @returns {void}
  361. */
  362. component.initFileBrowser = function initFileBrowser() {
  363. var $templateside = $( '#templateside' );
  364. // Collapse all folders.
  365. $templateside.find( '[role="group"]' ).parent().attr( 'aria-expanded', false );
  366. // Expand ancestors to the current file.
  367. $templateside.find( '.notice' ).parents( '[aria-expanded]' ).attr( 'aria-expanded', true );
  368. // Find Tree elements and enhance them.
  369. $templateside.find( '[role="tree"]' ).each( function() {
  370. var treeLinks = new TreeLinks( this );
  371. treeLinks.init();
  372. } );
  373. // Scroll the current file into view.
  374. $templateside.find( '.current-file:first' ).each( function() {
  375. if ( this.scrollIntoViewIfNeeded ) {
  376. this.scrollIntoViewIfNeeded();
  377. } else {
  378. this.scrollIntoView( false );
  379. }
  380. } );
  381. };
  382. /* jshint ignore:start */
  383. /* jscs:disable */
  384. /* eslint-disable */
  385. /**
  386. * Creates a new TreeitemLink.
  387. *
  388. * @since 4.9.0
  389. * @class
  390. * @private
  391. * @see {@link https://www.w3.org/TR/wai-aria-practices-1.1/examples/treeview/treeview-2/treeview-2b.html|W3C Treeview Example}
  392. * @license W3C-20150513
  393. */
  394. var TreeitemLink = (function () {
  395. /**
  396. * This content is licensed according to the W3C Software License at
  397. * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
  398. *
  399. * File: TreeitemLink.js
  400. *
  401. * Desc: Treeitem widget that implements ARIA Authoring Practices
  402. * for a tree being used as a file viewer
  403. *
  404. * Author: Jon Gunderson, Ku Ja Eun and Nicholas Hoyt
  405. */
  406. /**
  407. * @constructor
  408. *
  409. * @desc
  410. * Treeitem object for representing the state and user interactions for a
  411. * treeItem widget
  412. *
  413. * @param node
  414. * An element with the role=tree attribute
  415. */
  416. var TreeitemLink = function (node, treeObj, group) {
  417. // Check whether node is a DOM element
  418. if (typeof node !== 'object') {
  419. return;
  420. }
  421. node.tabIndex = -1;
  422. this.tree = treeObj;
  423. this.groupTreeitem = group;
  424. this.domNode = node;
  425. this.label = node.textContent.trim();
  426. this.stopDefaultClick = false;
  427. if (node.getAttribute('aria-label')) {
  428. this.label = node.getAttribute('aria-label').trim();
  429. }
  430. this.isExpandable = false;
  431. this.isVisible = false;
  432. this.inGroup = false;
  433. if (group) {
  434. this.inGroup = true;
  435. }
  436. var elem = node.firstElementChild;
  437. while (elem) {
  438. if (elem.tagName.toLowerCase() == 'ul') {
  439. elem.setAttribute('role', 'group');
  440. this.isExpandable = true;
  441. break;
  442. }
  443. elem = elem.nextElementSibling;
  444. }
  445. this.keyCode = Object.freeze({
  446. RETURN: 13,
  447. SPACE: 32,
  448. PAGEUP: 33,
  449. PAGEDOWN: 34,
  450. END: 35,
  451. HOME: 36,
  452. LEFT: 37,
  453. UP: 38,
  454. RIGHT: 39,
  455. DOWN: 40
  456. });
  457. };
  458. TreeitemLink.prototype.init = function () {
  459. this.domNode.tabIndex = -1;
  460. if (!this.domNode.getAttribute('role')) {
  461. this.domNode.setAttribute('role', 'treeitem');
  462. }
  463. this.domNode.addEventListener('keydown', this.handleKeydown.bind(this));
  464. this.domNode.addEventListener('click', this.handleClick.bind(this));
  465. this.domNode.addEventListener('focus', this.handleFocus.bind(this));
  466. this.domNode.addEventListener('blur', this.handleBlur.bind(this));
  467. if (this.isExpandable) {
  468. this.domNode.firstElementChild.addEventListener('mouseover', this.handleMouseOver.bind(this));
  469. this.domNode.firstElementChild.addEventListener('mouseout', this.handleMouseOut.bind(this));
  470. }
  471. else {
  472. this.domNode.addEventListener('mouseover', this.handleMouseOver.bind(this));
  473. this.domNode.addEventListener('mouseout', this.handleMouseOut.bind(this));
  474. }
  475. };
  476. TreeitemLink.prototype.isExpanded = function () {
  477. if (this.isExpandable) {
  478. return this.domNode.getAttribute('aria-expanded') === 'true';
  479. }
  480. return false;
  481. };
  482. /* EVENT HANDLERS */
  483. TreeitemLink.prototype.handleKeydown = function (event) {
  484. var tgt = event.currentTarget,
  485. flag = false,
  486. _char = event.key,
  487. clickEvent;
  488. function isPrintableCharacter(str) {
  489. return str.length === 1 && str.match(/\S/);
  490. }
  491. function printableCharacter(item) {
  492. if (_char == '*') {
  493. item.tree.expandAllSiblingItems(item);
  494. flag = true;
  495. }
  496. else {
  497. if (isPrintableCharacter(_char)) {
  498. item.tree.setFocusByFirstCharacter(item, _char);
  499. flag = true;
  500. }
  501. }
  502. }
  503. this.stopDefaultClick = false;
  504. if (event.altKey || event.ctrlKey || event.metaKey) {
  505. return;
  506. }
  507. if (event.shift) {
  508. if (event.keyCode == this.keyCode.SPACE || event.keyCode == this.keyCode.RETURN) {
  509. event.stopPropagation();
  510. this.stopDefaultClick = true;
  511. }
  512. else {
  513. if (isPrintableCharacter(_char)) {
  514. printableCharacter(this);
  515. }
  516. }
  517. }
  518. else {
  519. switch (event.keyCode) {
  520. case this.keyCode.SPACE:
  521. case this.keyCode.RETURN:
  522. if (this.isExpandable) {
  523. if (this.isExpanded()) {
  524. this.tree.collapseTreeitem(this);
  525. }
  526. else {
  527. this.tree.expandTreeitem(this);
  528. }
  529. flag = true;
  530. }
  531. else {
  532. event.stopPropagation();
  533. this.stopDefaultClick = true;
  534. }
  535. break;
  536. case this.keyCode.UP:
  537. this.tree.setFocusToPreviousItem(this);
  538. flag = true;
  539. break;
  540. case this.keyCode.DOWN:
  541. this.tree.setFocusToNextItem(this);
  542. flag = true;
  543. break;
  544. case this.keyCode.RIGHT:
  545. if (this.isExpandable) {
  546. if (this.isExpanded()) {
  547. this.tree.setFocusToNextItem(this);
  548. }
  549. else {
  550. this.tree.expandTreeitem(this);
  551. }
  552. }
  553. flag = true;
  554. break;
  555. case this.keyCode.LEFT:
  556. if (this.isExpandable && this.isExpanded()) {
  557. this.tree.collapseTreeitem(this);
  558. flag = true;
  559. }
  560. else {
  561. if (this.inGroup) {
  562. this.tree.setFocusToParentItem(this);
  563. flag = true;
  564. }
  565. }
  566. break;
  567. case this.keyCode.HOME:
  568. this.tree.setFocusToFirstItem();
  569. flag = true;
  570. break;
  571. case this.keyCode.END:
  572. this.tree.setFocusToLastItem();
  573. flag = true;
  574. break;
  575. default:
  576. if (isPrintableCharacter(_char)) {
  577. printableCharacter(this);
  578. }
  579. break;
  580. }
  581. }
  582. if (flag) {
  583. event.stopPropagation();
  584. event.preventDefault();
  585. }
  586. };
  587. TreeitemLink.prototype.handleClick = function (event) {
  588. // only process click events that directly happened on this treeitem
  589. if (event.target !== this.domNode && event.target !== this.domNode.firstElementChild) {
  590. return;
  591. }
  592. if (this.isExpandable) {
  593. if (this.isExpanded()) {
  594. this.tree.collapseTreeitem(this);
  595. }
  596. else {
  597. this.tree.expandTreeitem(this);
  598. }
  599. event.stopPropagation();
  600. }
  601. };
  602. TreeitemLink.prototype.handleFocus = function (event) {
  603. var node = this.domNode;
  604. if (this.isExpandable) {
  605. node = node.firstElementChild;
  606. }
  607. node.classList.add('focus');
  608. };
  609. TreeitemLink.prototype.handleBlur = function (event) {
  610. var node = this.domNode;
  611. if (this.isExpandable) {
  612. node = node.firstElementChild;
  613. }
  614. node.classList.remove('focus');
  615. };
  616. TreeitemLink.prototype.handleMouseOver = function (event) {
  617. event.currentTarget.classList.add('hover');
  618. };
  619. TreeitemLink.prototype.handleMouseOut = function (event) {
  620. event.currentTarget.classList.remove('hover');
  621. };
  622. return TreeitemLink;
  623. })();
  624. /**
  625. * Creates a new TreeLinks.
  626. *
  627. * @since 4.9.0
  628. * @class
  629. * @private
  630. * @see {@link https://www.w3.org/TR/wai-aria-practices-1.1/examples/treeview/treeview-2/treeview-2b.html|W3C Treeview Example}
  631. * @license W3C-20150513
  632. */
  633. TreeLinks = (function () {
  634. /*
  635. * This content is licensed according to the W3C Software License at
  636. * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
  637. *
  638. * File: TreeLinks.js
  639. *
  640. * Desc: Tree widget that implements ARIA Authoring Practices
  641. * for a tree being used as a file viewer
  642. *
  643. * Author: Jon Gunderson, Ku Ja Eun and Nicholas Hoyt
  644. */
  645. /*
  646. * @constructor
  647. *
  648. * @desc
  649. * Tree item object for representing the state and user interactions for a
  650. * tree widget
  651. *
  652. * @param node
  653. * An element with the role=tree attribute
  654. */
  655. var TreeLinks = function (node) {
  656. // Check whether node is a DOM element
  657. if (typeof node !== 'object') {
  658. return;
  659. }
  660. this.domNode = node;
  661. this.treeitems = [];
  662. this.firstChars = [];
  663. this.firstTreeitem = null;
  664. this.lastTreeitem = null;
  665. };
  666. TreeLinks.prototype.init = function () {
  667. function findTreeitems(node, tree, group) {
  668. var elem = node.firstElementChild;
  669. var ti = group;
  670. while (elem) {
  671. if ((elem.tagName.toLowerCase() === 'li' && elem.firstElementChild.tagName.toLowerCase() === 'span') || elem.tagName.toLowerCase() === 'a') {
  672. ti = new TreeitemLink(elem, tree, group);
  673. ti.init();
  674. tree.treeitems.push(ti);
  675. tree.firstChars.push(ti.label.substring(0, 1).toLowerCase());
  676. }
  677. if (elem.firstElementChild) {
  678. findTreeitems(elem, tree, ti);
  679. }
  680. elem = elem.nextElementSibling;
  681. }
  682. }
  683. // initialize pop up menus
  684. if (!this.domNode.getAttribute('role')) {
  685. this.domNode.setAttribute('role', 'tree');
  686. }
  687. findTreeitems(this.domNode, this, false);
  688. this.updateVisibleTreeitems();
  689. this.firstTreeitem.domNode.tabIndex = 0;
  690. };
  691. TreeLinks.prototype.setFocusToItem = function (treeitem) {
  692. for (var i = 0; i < this.treeitems.length; i++) {
  693. var ti = this.treeitems[i];
  694. if (ti === treeitem) {
  695. ti.domNode.tabIndex = 0;
  696. ti.domNode.focus();
  697. }
  698. else {
  699. ti.domNode.tabIndex = -1;
  700. }
  701. }
  702. };
  703. TreeLinks.prototype.setFocusToNextItem = function (currentItem) {
  704. var nextItem = false;
  705. for (var i = (this.treeitems.length - 1); i >= 0; i--) {
  706. var ti = this.treeitems[i];
  707. if (ti === currentItem) {
  708. break;
  709. }
  710. if (ti.isVisible) {
  711. nextItem = ti;
  712. }
  713. }
  714. if (nextItem) {
  715. this.setFocusToItem(nextItem);
  716. }
  717. };
  718. TreeLinks.prototype.setFocusToPreviousItem = function (currentItem) {
  719. var prevItem = false;
  720. for (var i = 0; i < this.treeitems.length; i++) {
  721. var ti = this.treeitems[i];
  722. if (ti === currentItem) {
  723. break;
  724. }
  725. if (ti.isVisible) {
  726. prevItem = ti;
  727. }
  728. }
  729. if (prevItem) {
  730. this.setFocusToItem(prevItem);
  731. }
  732. };
  733. TreeLinks.prototype.setFocusToParentItem = function (currentItem) {
  734. if (currentItem.groupTreeitem) {
  735. this.setFocusToItem(currentItem.groupTreeitem);
  736. }
  737. };
  738. TreeLinks.prototype.setFocusToFirstItem = function () {
  739. this.setFocusToItem(this.firstTreeitem);
  740. };
  741. TreeLinks.prototype.setFocusToLastItem = function () {
  742. this.setFocusToItem(this.lastTreeitem);
  743. };
  744. TreeLinks.prototype.expandTreeitem = function (currentItem) {
  745. if (currentItem.isExpandable) {
  746. currentItem.domNode.setAttribute('aria-expanded', true);
  747. this.updateVisibleTreeitems();
  748. }
  749. };
  750. TreeLinks.prototype.expandAllSiblingItems = function (currentItem) {
  751. for (var i = 0; i < this.treeitems.length; i++) {
  752. var ti = this.treeitems[i];
  753. if ((ti.groupTreeitem === currentItem.groupTreeitem) && ti.isExpandable) {
  754. this.expandTreeitem(ti);
  755. }
  756. }
  757. };
  758. TreeLinks.prototype.collapseTreeitem = function (currentItem) {
  759. var groupTreeitem = false;
  760. if (currentItem.isExpanded()) {
  761. groupTreeitem = currentItem;
  762. }
  763. else {
  764. groupTreeitem = currentItem.groupTreeitem;
  765. }
  766. if (groupTreeitem) {
  767. groupTreeitem.domNode.setAttribute('aria-expanded', false);
  768. this.updateVisibleTreeitems();
  769. this.setFocusToItem(groupTreeitem);
  770. }
  771. };
  772. TreeLinks.prototype.updateVisibleTreeitems = function () {
  773. this.firstTreeitem = this.treeitems[0];
  774. for (var i = 0; i < this.treeitems.length; i++) {
  775. var ti = this.treeitems[i];
  776. var parent = ti.domNode.parentNode;
  777. ti.isVisible = true;
  778. while (parent && (parent !== this.domNode)) {
  779. if (parent.getAttribute('aria-expanded') == 'false') {
  780. ti.isVisible = false;
  781. }
  782. parent = parent.parentNode;
  783. }
  784. if (ti.isVisible) {
  785. this.lastTreeitem = ti;
  786. }
  787. }
  788. };
  789. TreeLinks.prototype.setFocusByFirstCharacter = function (currentItem, _char) {
  790. var start, index;
  791. _char = _char.toLowerCase();
  792. // Get start index for search based on position of currentItem
  793. start = this.treeitems.indexOf(currentItem) + 1;
  794. if (start === this.treeitems.length) {
  795. start = 0;
  796. }
  797. // Check remaining slots in the menu
  798. index = this.getIndexFirstChars(start, _char);
  799. // If not found in remaining slots, check from beginning
  800. if (index === -1) {
  801. index = this.getIndexFirstChars(0, _char);
  802. }
  803. // If match was found...
  804. if (index > -1) {
  805. this.setFocusToItem(this.treeitems[index]);
  806. }
  807. };
  808. TreeLinks.prototype.getIndexFirstChars = function (startIndex, _char) {
  809. for (var i = startIndex; i < this.firstChars.length; i++) {
  810. if (this.treeitems[i].isVisible) {
  811. if (_char === this.firstChars[i]) {
  812. return i;
  813. }
  814. }
  815. }
  816. return -1;
  817. };
  818. return TreeLinks;
  819. })();
  820. /* jshint ignore:end */
  821. /* jscs:enable */
  822. /* eslint-enable */
  823. return component;
  824. })( jQuery );