theme-plugin-editor.js 24 KB

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