diff --git a/core/misc/active-link.es6.js b/core/misc/active-link.es6.js new file mode 100644 index 0000000000..9cf55b4344 --- /dev/null +++ b/core/misc/active-link.es6.js @@ -0,0 +1,68 @@ +/** + * @file + * Attaches behaviors for Drupal's active link marking. + */ + +(function (Drupal, drupalSettings) { + + 'use strict'; + + /** + * Append is-active class. + * + * The link is only active if its path corresponds to the current path, the + * language of the linked path is equal to the current language, and if the + * query parameters of the link equal those of the current request, since the + * same request with different query parameters may yield a different page + * (e.g. pagers, exposed View filters). + * + * Does not discriminate based on element type, so allows you to set the + * is-active class on any element: a, li… + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.activeLinks = { + attach: function (context) { + // Start by finding all potentially active links. + var path = drupalSettings.path; + var queryString = JSON.stringify(path.currentQuery); + var querySelector = path.currentQuery ? "[data-drupal-link-query='" + queryString + "']" : ':not([data-drupal-link-query])'; + var originalSelectors = ['[data-drupal-link-system-path="' + path.currentPath + '"]']; + var selectors; + + // If this is the front page, we have to check for the path as + // well. + if (path.isFront) { + originalSelectors.push('[data-drupal-link-system-path=""]'); + } + + // Add language filtering. + selectors = [].concat( + // Links without any hreflang attributes (most of them). + originalSelectors.map(function (selector) { return selector + ':not([hreflang])'; }), + // Links with hreflang equals to the current language. + originalSelectors.map(function (selector) { return selector + '[hreflang="' + path.currentLanguage + '"]'; }) + ); + + // Add query string selector for pagers, exposed filters. + selectors = selectors.map(function (current) { return current + querySelector; }); + + // Query the DOM. + var activeLinks = context.querySelectorAll(selectors.join(',')); + var il = activeLinks.length; + for (var i = 0; i < il; i++) { + activeLinks[i].classList.add('is-active'); + } + }, + detach: function (context, settings, trigger) { + if (trigger === 'unload') { + var activeLinks = context.querySelectorAll('[data-drupal-link-system-path].is-active'); + var il = activeLinks.length; + for (var i = 0; i < il; i++) { + activeLinks[i].classList.remove('is-active'); + } + } + } + }; + +})(Drupal, drupalSettings); diff --git a/core/misc/ajax.es6.js b/core/misc/ajax.es6.js new file mode 100644 index 0000000000..fefe9f3031 --- /dev/null +++ b/core/misc/ajax.es6.js @@ -0,0 +1,1344 @@ +/** + * @file + * Provides Ajax page updating via jQuery $.ajax. + * + * Ajax is a method of making a request via JavaScript while viewing an HTML + * page. The request returns an array of commands encoded in JSON, which is + * then executed to make any changes that are necessary to the page. + * + * Drupal uses this file to enhance form elements with `#ajax['url']` and + * `#ajax['wrapper']` properties. If set, this file will automatically be + * included to provide Ajax capabilities. + */ + +(function ($, window, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Attaches the Ajax behavior to each Ajax form element. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Initialize all {@link Drupal.Ajax} objects declared in + * `drupalSettings.ajax` or initialize {@link Drupal.Ajax} objects from + * DOM elements having the `use-ajax-submit` or `use-ajax` css class. + * @prop {Drupal~behaviorDetach} detach + * During `unload` remove all {@link Drupal.Ajax} objects related to + * the removed content. + */ + Drupal.behaviors.AJAX = { + attach: function (context, settings) { + + function loadAjaxBehavior(base) { + var element_settings = settings.ajax[base]; + if (typeof element_settings.selector === 'undefined') { + element_settings.selector = '#' + base; + } + $(element_settings.selector).once('drupal-ajax').each(function () { + element_settings.element = this; + element_settings.base = base; + Drupal.ajax(element_settings); + }); + } + + // Load all Ajax behaviors specified in the settings. + for (var base in settings.ajax) { + if (settings.ajax.hasOwnProperty(base)) { + loadAjaxBehavior(base); + } + } + + // Bind Ajax behaviors to all items showing the class. + $('.use-ajax').once('ajax').each(function () { + var element_settings = {}; + // Clicked links look better with the throbber than the progress bar. + element_settings.progress = {type: 'throbber'}; + + // For anchor tags, these will go to the target of the anchor rather + // than the usual location. + var href = $(this).attr('href'); + if (href) { + element_settings.url = href; + element_settings.event = 'click'; + } + element_settings.dialogType = $(this).data('dialog-type'); + element_settings.dialog = $(this).data('dialog-options'); + element_settings.base = $(this).attr('id'); + element_settings.element = this; + Drupal.ajax(element_settings); + }); + + // This class means to submit the form to the action using Ajax. + $('.use-ajax-submit').once('ajax').each(function () { + var element_settings = {}; + + // Ajax submits specified in this manner automatically submit to the + // normal form action. + element_settings.url = $(this.form).attr('action'); + // Form submit button clicks need to tell the form what was clicked so + // it gets passed in the POST request. + element_settings.setClick = true; + // Form buttons use the 'click' event rather than mousedown. + element_settings.event = 'click'; + // Clicked form buttons look better with the throbber than the progress + // bar. + element_settings.progress = {type: 'throbber'}; + element_settings.base = $(this).attr('id'); + element_settings.element = this; + + Drupal.ajax(element_settings); + }); + }, + + detach: function (context, settings, trigger) { + if (trigger === 'unload') { + Drupal.ajax.expired().forEach(function (instance) { + // Set this to null and allow garbage collection to reclaim + // the memory. + Drupal.ajax.instances[instance.instanceIndex] = null; + }); + } + } + }; + + /** + * Extends Error to provide handling for Errors in Ajax. + * + * @constructor + * + * @augments Error + * + * @param {XMLHttpRequest} xmlhttp + * XMLHttpRequest object used for the failed request. + * @param {string} uri + * The URI where the error occurred. + * @param {string} customMessage + * The custom message. + */ + Drupal.AjaxError = function (xmlhttp, uri, customMessage) { + + var statusCode; + var statusText; + var pathText; + var responseText; + var readyStateText; + if (xmlhttp.status) { + statusCode = '\n' + Drupal.t('An AJAX HTTP error occurred.') + '\n' + Drupal.t('HTTP Result Code: !status', {'!status': xmlhttp.status}); + } + else { + statusCode = '\n' + Drupal.t('An AJAX HTTP request terminated abnormally.'); + } + statusCode += '\n' + Drupal.t('Debugging information follows.'); + pathText = '\n' + Drupal.t('Path: !uri', {'!uri': uri}); + statusText = ''; + // In some cases, when statusCode === 0, xmlhttp.statusText may not be + // defined. Unfortunately, testing for it with typeof, etc, doesn't seem to + // catch that and the test causes an exception. So we need to catch the + // exception here. + try { + statusText = '\n' + Drupal.t('StatusText: !statusText', {'!statusText': $.trim(xmlhttp.statusText)}); + } + catch (e) { + // Empty. + } + + responseText = ''; + // Again, we don't have a way to know for sure whether accessing + // xmlhttp.responseText is going to throw an exception. So we'll catch it. + try { + responseText = '\n' + Drupal.t('ResponseText: !responseText', {'!responseText': $.trim(xmlhttp.responseText)}); + } + catch (e) { + // Empty. + } + + // Make the responseText more readable by stripping HTML tags and newlines. + responseText = responseText.replace(/<("[^"]*"|'[^']*'|[^'">])*>/gi, ''); + responseText = responseText.replace(/[\n]+\s+/g, '\n'); + + // We don't need readyState except for status == 0. + readyStateText = xmlhttp.status === 0 ? ('\n' + Drupal.t('ReadyState: !readyState', {'!readyState': xmlhttp.readyState})) : ''; + + customMessage = customMessage ? ('\n' + Drupal.t('CustomMessage: !customMessage', {'!customMessage': customMessage})) : ''; + + /** + * Formatted and translated error message. + * + * @type {string} + */ + this.message = statusCode + pathText + statusText + customMessage + responseText + readyStateText; + + /** + * Used by some browsers to display a more accurate stack trace. + * + * @type {string} + */ + this.name = 'AjaxError'; + }; + + Drupal.AjaxError.prototype = new Error(); + Drupal.AjaxError.prototype.constructor = Drupal.AjaxError; + + /** + * Provides Ajax page updating via jQuery $.ajax. + * + * This function is designed to improve developer experience by wrapping the + * initialization of {@link Drupal.Ajax} objects and storing all created + * objects in the {@link Drupal.ajax.instances} array. + * + * @example + * Drupal.behaviors.myCustomAJAXStuff = { + * attach: function (context, settings) { + * + * var ajaxSettings = { + * url: 'my/url/path', + * // If the old version of Drupal.ajax() needs to be used those + * // properties can be added + * base: 'myBase', + * element: $(context).find('.someElement') + * }; + * + * var myAjaxObject = Drupal.ajax(ajaxSettings); + * + * // Declare a new Ajax command specifically for this Ajax object. + * myAjaxObject.commands.insert = function (ajax, response, status) { + * $('#my-wrapper').append(response.data); + * alert('New content was appended to #my-wrapper'); + * }; + * + * // This command will remove this Ajax object from the page. + * myAjaxObject.commands.destroyObject = function (ajax, response, status) { + * Drupal.ajax.instances[this.instanceIndex] = null; + * }; + * + * // Programmatically trigger the Ajax request. + * myAjaxObject.execute(); + * } + * }; + * + * @param {object} settings + * The settings object passed to {@link Drupal.Ajax} constructor. + * @param {string} [settings.base] + * Base is passed to {@link Drupal.Ajax} constructor as the 'base' + * parameter. + * @param {HTMLElement} [settings.element] + * Element parameter of {@link Drupal.Ajax} constructor, element on which + * event listeners will be bound. + * + * @return {Drupal.Ajax} + * The created Ajax object. + * + * @see Drupal.AjaxCommands + */ + Drupal.ajax = function (settings) { + if (arguments.length !== 1) { + throw new Error('Drupal.ajax() function must be called with one configuration object only'); + } + // Map those config keys to variables for the old Drupal.ajax function. + var base = settings.base || false; + var element = settings.element || false; + delete settings.base; + delete settings.element; + + // By default do not display progress for ajax calls without an element. + if (!settings.progress && !element) { + settings.progress = false; + } + + var ajax = new Drupal.Ajax(base, element, settings); + ajax.instanceIndex = Drupal.ajax.instances.length; + Drupal.ajax.instances.push(ajax); + + return ajax; + }; + + /** + * Contains all created Ajax objects. + * + * @type {Array.} + */ + Drupal.ajax.instances = []; + + /** + * List all objects where the associated element is not in the DOM + * + * This method ignores {@link Drupal.Ajax} objects not bound to DOM elements + * when created with {@link Drupal.ajax}. + * + * @return {Array.} + * The list of expired {@link Drupal.Ajax} objects. + */ + Drupal.ajax.expired = function () { + return Drupal.ajax.instances.filter(function (instance) { + return instance && instance.element !== false && !document.body.contains(instance.element); + }); + }; + + /** + * Settings for an Ajax object. + * + * @typedef {object} Drupal.Ajax~element_settings + * + * @prop {string} url + * Target of the Ajax request. + * @prop {?string} [event] + * Event bound to settings.element which will trigger the Ajax request. + * @prop {bool} [keypress=true] + * Triggers a request on keypress events. + * @prop {?string} selector + * jQuery selector targeting the element to bind events to or used with + * {@link Drupal.AjaxCommands}. + * @prop {string} [effect='none'] + * Name of the jQuery method to use for displaying new Ajax content. + * @prop {string|number} [speed='none'] + * Speed with which to apply the effect. + * @prop {string} [method] + * Name of the jQuery method used to insert new content in the targeted + * element. + * @prop {object} [progress] + * Settings for the display of a user-friendly loader. + * @prop {string} [progress.type='throbber'] + * Type of progress element, core provides `'bar'`, `'throbber'` and + * `'fullscreen'`. + * @prop {string} [progress.message=Drupal.t('Please wait...')] + * Custom message to be used with the bar indicator. + * @prop {object} [submit] + * Extra data to be sent with the Ajax request. + * @prop {bool} [submit.js=true] + * Allows the PHP side to know this comes from an Ajax request. + * @prop {object} [dialog] + * Options for {@link Drupal.dialog}. + * @prop {string} [dialogType] + * One of `'modal'` or `'dialog'`. + * @prop {string} [prevent] + * List of events on which to stop default action and stop propagation. + */ + + /** + * Ajax constructor. + * + * The Ajax request returns an array of commands encoded in JSON, which is + * then executed to make any changes that are necessary to the page. + * + * Drupal uses this file to enhance form elements with `#ajax['url']` and + * `#ajax['wrapper']` properties. If set, this file will automatically be + * included to provide Ajax capabilities. + * + * @constructor + * + * @param {string} [base] + * Base parameter of {@link Drupal.Ajax} constructor + * @param {HTMLElement} [element] + * Element parameter of {@link Drupal.Ajax} constructor, element on which + * event listeners will be bound. + * @param {Drupal.Ajax~element_settings} element_settings + * Settings for this Ajax object. + */ + Drupal.Ajax = function (base, element, element_settings) { + var defaults = { + event: element ? 'mousedown' : null, + keypress: true, + selector: base ? '#' + base : null, + effect: 'none', + speed: 'none', + method: 'replaceWith', + progress: { + type: 'throbber', + message: Drupal.t('Please wait...') + }, + submit: { + js: true + } + }; + + $.extend(this, defaults, element_settings); + + /** + * @type {Drupal.AjaxCommands} + */ + this.commands = new Drupal.AjaxCommands(); + + /** + * @type {bool|number} + */ + this.instanceIndex = false; + + // @todo Remove this after refactoring the PHP code to: + // - Call this 'selector'. + // - Include the '#' for ID-based selectors. + // - Support non-ID-based selectors. + if (this.wrapper) { + + /** + * @type {string} + */ + this.wrapper = '#' + this.wrapper; + } + + /** + * @type {HTMLElement} + */ + this.element = element; + + /** + * @type {Drupal.Ajax~element_settings} + */ + this.element_settings = element_settings; + + // If there isn't a form, jQuery.ajax() will be used instead, allowing us to + // bind Ajax to links as well. + if (this.element && this.element.form) { + + /** + * @type {jQuery} + */ + this.$form = $(this.element.form); + } + + // If no Ajax callback URL was given, use the link href or form action. + if (!this.url) { + var $element = $(this.element); + if ($element.is('a')) { + this.url = $element.attr('href'); + } + else if (this.element && element.form) { + this.url = this.$form.attr('action'); + } + } + + // Replacing 'nojs' with 'ajax' in the URL allows for an easy method to let + // the server detect when it needs to degrade gracefully. + // There are four scenarios to check for: + // 1. /nojs/ + // 2. /nojs$ - The end of a URL string. + // 3. /nojs? - Followed by a query (e.g. path/nojs?destination=foobar). + // 4. /nojs# - Followed by a fragment (e.g.: path/nojs#myfragment). + var originalUrl = this.url; + + /** + * Processed Ajax URL. + * + * @type {string} + */ + this.url = this.url.replace(/\/nojs(\/|$|\?|#)/g, '/ajax$1'); + // If the 'nojs' version of the URL is trusted, also trust the 'ajax' + // version. + if (drupalSettings.ajaxTrustedUrl[originalUrl]) { + drupalSettings.ajaxTrustedUrl[this.url] = true; + } + + // Set the options for the ajaxSubmit function. + // The 'this' variable will not persist inside of the options object. + var ajax = this; + + /** + * Options for the jQuery.ajax function. + * + * @name Drupal.Ajax#options + * + * @type {object} + * + * @prop {string} url + * Ajax URL to be called. + * @prop {object} data + * Ajax payload. + * @prop {function} beforeSerialize + * Implement jQuery beforeSerialize function to call + * {@link Drupal.Ajax#beforeSerialize}. + * @prop {function} beforeSubmit + * Implement jQuery beforeSubmit function to call + * {@link Drupal.Ajax#beforeSubmit}. + * @prop {function} beforeSend + * Implement jQuery beforeSend function to call + * {@link Drupal.Ajax#beforeSend}. + * @prop {function} success + * Implement jQuery success function to call + * {@link Drupal.Ajax#success}. + * @prop {function} complete + * Implement jQuery success function to clean up ajax state and trigger an + * error if needed. + * @prop {string} dataType='json' + * Type of the response expected. + * @prop {string} type='POST' + * HTTP method to use for the Ajax request. + */ + ajax.options = { + url: ajax.url, + data: ajax.submit, + beforeSerialize: function (element_settings, options) { + return ajax.beforeSerialize(element_settings, options); + }, + beforeSubmit: function (form_values, element_settings, options) { + ajax.ajaxing = true; + return ajax.beforeSubmit(form_values, element_settings, options); + }, + beforeSend: function (xmlhttprequest, options) { + ajax.ajaxing = true; + return ajax.beforeSend(xmlhttprequest, options); + }, + success: function (response, status, xmlhttprequest) { + // Sanity check for browser support (object expected). + // When using iFrame uploads, responses must be returned as a string. + if (typeof response === 'string') { + response = $.parseJSON(response); + } + + // Prior to invoking the response's commands, verify that they can be + // trusted by checking for a response header. See + // \Drupal\Core\EventSubscriber\AjaxResponseSubscriber for details. + // - Empty responses are harmless so can bypass verification. This + // avoids an alert message for server-generated no-op responses that + // skip Ajax rendering. + // - Ajax objects with trusted URLs (e.g., ones defined server-side via + // #ajax) can bypass header verification. This is especially useful + // for Ajax with multipart forms. Because IFRAME transport is used, + // the response headers cannot be accessed for verification. + if (response !== null && !drupalSettings.ajaxTrustedUrl[ajax.url]) { + if (xmlhttprequest.getResponseHeader('X-Drupal-Ajax-Token') !== '1') { + var customMessage = Drupal.t('The response failed verification so will not be processed.'); + return ajax.error(xmlhttprequest, ajax.url, customMessage); + } + } + + return ajax.success(response, status); + }, + complete: function (xmlhttprequest, status) { + ajax.ajaxing = false; + if (status === 'error' || status === 'parsererror') { + return ajax.error(xmlhttprequest, ajax.url); + } + }, + dataType: 'json', + type: 'POST' + }; + + if (element_settings.dialog) { + ajax.options.data.dialogOptions = element_settings.dialog; + } + + // Ensure that we have a valid URL by adding ? when no query parameter is + // yet available, otherwise append using &. + if (ajax.options.url.indexOf('?') === -1) { + ajax.options.url += '?'; + } + else { + ajax.options.url += '&'; + } + ajax.options.url += Drupal.ajax.WRAPPER_FORMAT + '=drupal_' + (element_settings.dialogType || 'ajax'); + + // Bind the ajaxSubmit function to the element event. + $(ajax.element).on(element_settings.event, function (event) { + if (!drupalSettings.ajaxTrustedUrl[ajax.url] && !Drupal.url.isLocal(ajax.url)) { + throw new Error(Drupal.t('The callback URL is not local and not trusted: !url', {'!url': ajax.url})); + } + return ajax.eventResponse(this, event); + }); + + // If necessary, enable keyboard submission so that Ajax behaviors + // can be triggered through keyboard input as well as e.g. a mousedown + // action. + if (element_settings.keypress) { + $(ajax.element).on('keypress', function (event) { + return ajax.keypressResponse(this, event); + }); + } + + // If necessary, prevent the browser default action of an additional event. + // For example, prevent the browser default action of a click, even if the + // Ajax behavior binds to mousedown. + if (element_settings.prevent) { + $(ajax.element).on(element_settings.prevent, false); + } + }; + + /** + * URL query attribute to indicate the wrapper used to render a request. + * + * The wrapper format determines how the HTML is wrapped, for example in a + * modal dialog. + * + * @const {string} + * + * @default + */ + Drupal.ajax.WRAPPER_FORMAT = '_wrapper_format'; + + /** + * Request parameter to indicate that a request is a Drupal Ajax request. + * + * @const {string} + * + * @default + */ + Drupal.Ajax.AJAX_REQUEST_PARAMETER = '_drupal_ajax'; + + /** + * Execute the ajax request. + * + * Allows developers to execute an Ajax request manually without specifying + * an event to respond to. + * + * @return {object} + * Returns the jQuery.Deferred object underlying the Ajax request. If + * pre-serialization fails, the Deferred will be returned in the rejected + * state. + */ + Drupal.Ajax.prototype.execute = function () { + // Do not perform another ajax command if one is already in progress. + if (this.ajaxing) { + return; + } + + try { + this.beforeSerialize(this.element, this.options); + // Return the jqXHR so that external code can hook into the Deferred API. + return $.ajax(this.options); + } + catch (e) { + // Unset the ajax.ajaxing flag here because it won't be unset during + // the complete response. + this.ajaxing = false; + window.alert('An error occurred while attempting to process ' + this.options.url + ': ' + e.message); + // For consistency, return a rejected Deferred (i.e., jqXHR's superclass) + // so that calling code can take appropriate action. + return $.Deferred().reject(); + } + }; + + /** + * Handle a key press. + * + * The Ajax object will, if instructed, bind to a key press response. This + * will test to see if the key press is valid to trigger this event and + * if it is, trigger it for us and prevent other keypresses from triggering. + * In this case we're handling RETURN and SPACEBAR keypresses (event codes 13 + * and 32. RETURN is often used to submit a form when in a textfield, and + * SPACE is often used to activate an element without submitting. + * + * @param {HTMLElement} element + * Element the event was triggered on. + * @param {jQuery.Event} event + * Triggered event. + */ + Drupal.Ajax.prototype.keypressResponse = function (element, event) { + // Create a synonym for this to reduce code confusion. + var ajax = this; + + // Detect enter key and space bar and allow the standard response for them, + // except for form elements of type 'text', 'tel', 'number' and 'textarea', + // where the spacebar activation causes inappropriate activation if + // #ajax['keypress'] is TRUE. On a text-type widget a space should always + // be a space. + if (event.which === 13 || (event.which === 32 && element.type !== 'text' && + element.type !== 'textarea' && element.type !== 'tel' && element.type !== 'number')) { + event.preventDefault(); + event.stopPropagation(); + $(ajax.element_settings.element).trigger(ajax.element_settings.event); + } + }; + + /** + * Handle an event that triggers an Ajax response. + * + * When an event that triggers an Ajax response happens, this method will + * perform the actual Ajax call. It is bound to the event using + * bind() in the constructor, and it uses the options specified on the + * Ajax object. + * + * @param {HTMLElement} element + * Element the event was triggered on. + * @param {jQuery.Event} event + * Triggered event. + */ + Drupal.Ajax.prototype.eventResponse = function (element, event) { + event.preventDefault(); + event.stopPropagation(); + + // Create a synonym for this to reduce code confusion. + var ajax = this; + + // Do not perform another Ajax command if one is already in progress. + if (ajax.ajaxing) { + return; + } + + try { + if (ajax.$form) { + // If setClick is set, we must set this to ensure that the button's + // value is passed. + if (ajax.setClick) { + // Mark the clicked button. 'form.clk' is a special variable for + // ajaxSubmit that tells the system which element got clicked to + // trigger the submit. Without it there would be no 'op' or + // equivalent. + element.form.clk = element; + } + + ajax.$form.ajaxSubmit(ajax.options); + } + else { + ajax.beforeSerialize(ajax.element, ajax.options); + $.ajax(ajax.options); + } + } + catch (e) { + // Unset the ajax.ajaxing flag here because it won't be unset during + // the complete response. + ajax.ajaxing = false; + window.alert('An error occurred while attempting to process ' + ajax.options.url + ': ' + e.message); + } + }; + + /** + * Handler for the form serialization. + * + * Runs before the beforeSend() handler (see below), and unlike that one, runs + * before field data is collected. + * + * @param {object} [element] + * Ajax object's `element_settings`. + * @param {object} options + * jQuery.ajax options. + */ + Drupal.Ajax.prototype.beforeSerialize = function (element, options) { + // Allow detaching behaviors to update field values before collecting them. + // This is only needed when field values are added to the POST data, so only + // when there is a form such that this.$form.ajaxSubmit() is used instead of + // $.ajax(). When there is no form and $.ajax() is used, beforeSerialize() + // isn't called, but don't rely on that: explicitly check this.$form. + if (this.$form) { + var settings = this.settings || drupalSettings; + Drupal.detachBehaviors(this.$form.get(0), settings, 'serialize'); + } + + // Inform Drupal that this is an AJAX request. + options.data[Drupal.Ajax.AJAX_REQUEST_PARAMETER] = 1; + + // Allow Drupal to return new JavaScript and CSS files to load without + // returning the ones already loaded. + // @see \Drupal\Core\Theme\AjaxBasePageNegotiator + // @see \Drupal\Core\Asset\LibraryDependencyResolverInterface::getMinimalRepresentativeSubset() + // @see system_js_settings_alter() + var pageState = drupalSettings.ajaxPageState; + options.data['ajax_page_state[theme]'] = pageState.theme; + options.data['ajax_page_state[theme_token]'] = pageState.theme_token; + options.data['ajax_page_state[libraries]'] = pageState.libraries; + }; + + /** + * Modify form values prior to form submission. + * + * @param {Array.} form_values + * Processed form values. + * @param {jQuery} element + * The form node as a jQuery object. + * @param {object} options + * jQuery.ajax options. + */ + Drupal.Ajax.prototype.beforeSubmit = function (form_values, element, options) { + // This function is left empty to make it simple to override for modules + // that wish to add functionality here. + }; + + /** + * Prepare the Ajax request before it is sent. + * + * @param {XMLHttpRequest} xmlhttprequest + * Native Ajax object. + * @param {object} options + * jQuery.ajax options. + */ + Drupal.Ajax.prototype.beforeSend = function (xmlhttprequest, options) { + // For forms without file inputs, the jQuery Form plugin serializes the + // form values, and then calls jQuery's $.ajax() function, which invokes + // this handler. In this circumstance, options.extraData is never used. For + // forms with file inputs, the jQuery Form plugin uses the browser's normal + // form submission mechanism, but captures the response in a hidden IFRAME. + // In this circumstance, it calls this handler first, and then appends + // hidden fields to the form to submit the values in options.extraData. + // There is no simple way to know which submission mechanism will be used, + // so we add to extraData regardless, and allow it to be ignored in the + // former case. + if (this.$form) { + options.extraData = options.extraData || {}; + + // Let the server know when the IFRAME submission mechanism is used. The + // server can use this information to wrap the JSON response in a + // TEXTAREA, as per http://jquery.malsup.com/form/#file-upload. + options.extraData.ajax_iframe_upload = '1'; + + // The triggering element is about to be disabled (see below), but if it + // contains a value (e.g., a checkbox, textfield, select, etc.), ensure + // that value is included in the submission. As per above, submissions + // that use $.ajax() are already serialized prior to the element being + // disabled, so this is only needed for IFRAME submissions. + var v = $.fieldValue(this.element); + if (v !== null) { + options.extraData[this.element.name] = v; + } + } + + // Disable the element that received the change to prevent user interface + // interaction while the Ajax request is in progress. ajax.ajaxing prevents + // the element from triggering a new request, but does not prevent the user + // from changing its value. + $(this.element).prop('disabled', true); + + if (!this.progress || !this.progress.type) { + return; + } + + // Insert progress indicator. + var progressIndicatorMethod = 'setProgressIndicator' + this.progress.type.slice(0, 1).toUpperCase() + this.progress.type.slice(1).toLowerCase(); + if (progressIndicatorMethod in this && typeof this[progressIndicatorMethod] === 'function') { + this[progressIndicatorMethod].call(this); + } + }; + + /** + * Sets the progress bar progress indicator. + */ + Drupal.Ajax.prototype.setProgressIndicatorBar = function () { + var progressBar = new Drupal.ProgressBar('ajax-progress-' + this.element.id, $.noop, this.progress.method, $.noop); + if (this.progress.message) { + progressBar.setProgress(-1, this.progress.message); + } + if (this.progress.url) { + progressBar.startMonitoring(this.progress.url, this.progress.interval || 1500); + } + this.progress.element = $(progressBar.element).addClass('ajax-progress ajax-progress-bar'); + this.progress.object = progressBar; + $(this.element).after(this.progress.element); + }; + + /** + * Sets the throbber progress indicator. + */ + Drupal.Ajax.prototype.setProgressIndicatorThrobber = function () { + this.progress.element = $('
 
'); + if (this.progress.message) { + this.progress.element.find('.throbber').after('
' + this.progress.message + '
'); + } + $(this.element).after(this.progress.element); + }; + + /** + * Sets the fullscreen progress indicator. + */ + Drupal.Ajax.prototype.setProgressIndicatorFullscreen = function () { + this.progress.element = $('
 
'); + $('body').after(this.progress.element); + }; + + /** + * Handler for the form redirection completion. + * + * @param {Array.} response + * Drupal Ajax response. + * @param {number} status + * XMLHttpRequest status. + */ + Drupal.Ajax.prototype.success = function (response, status) { + // Remove the progress element. + if (this.progress.element) { + $(this.progress.element).remove(); + } + if (this.progress.object) { + this.progress.object.stopMonitoring(); + } + $(this.element).prop('disabled', false); + + // Save element's ancestors tree so if the element is removed from the dom + // we can try to refocus one of its parents. Using addBack reverse the + // result array, meaning that index 0 is the highest parent in the hierarchy + // in this situation it is usually a
element. + var elementParents = $(this.element).parents('[data-drupal-selector]').addBack().toArray(); + + // Track if any command is altering the focus so we can avoid changing the + // focus set by the Ajax command. + var focusChanged = false; + for (var i in response) { + if (response.hasOwnProperty(i) && response[i].command && this.commands[response[i].command]) { + this.commands[response[i].command](this, response[i], status); + if (response[i].command === 'invoke' && response[i].method === 'focus') { + focusChanged = true; + } + } + } + + // If the focus hasn't be changed by the ajax commands, try to refocus the + // triggering element or one of its parents if that element does not exist + // anymore. + if (!focusChanged && this.element && !$(this.element).data('disable-refocus')) { + var target = false; + + for (var n = elementParents.length - 1; !target && n > 0; n--) { + target = document.querySelector('[data-drupal-selector="' + elementParents[n].getAttribute('data-drupal-selector') + '"]'); + } + + if (target) { + $(target).trigger('focus'); + } + } + + // Reattach behaviors, if they were detached in beforeSerialize(). The + // attachBehaviors() called on the new content from processing the response + // commands is not sufficient, because behaviors from the entire form need + // to be reattached. + if (this.$form) { + var settings = this.settings || drupalSettings; + Drupal.attachBehaviors(this.$form.get(0), settings); + } + + // Remove any response-specific settings so they don't get used on the next + // call by mistake. + this.settings = null; + }; + + /** + * Build an effect object to apply an effect when adding new HTML. + * + * @param {object} response + * Drupal Ajax response. + * @param {string} [response.effect] + * Override the default value of {@link Drupal.Ajax#element_settings}. + * @param {string|number} [response.speed] + * Override the default value of {@link Drupal.Ajax#element_settings}. + * + * @return {object} + * Returns an object with `showEffect`, `hideEffect` and `showSpeed` + * properties. + */ + Drupal.Ajax.prototype.getEffect = function (response) { + var type = response.effect || this.effect; + var speed = response.speed || this.speed; + + var effect = {}; + if (type === 'none') { + effect.showEffect = 'show'; + effect.hideEffect = 'hide'; + effect.showSpeed = ''; + } + else if (type === 'fade') { + effect.showEffect = 'fadeIn'; + effect.hideEffect = 'fadeOut'; + effect.showSpeed = speed; + } + else { + effect.showEffect = type + 'Toggle'; + effect.hideEffect = type + 'Toggle'; + effect.showSpeed = speed; + } + + return effect; + }; + + /** + * Handler for the form redirection error. + * + * @param {object} xmlhttprequest + * Native XMLHttpRequest object. + * @param {string} uri + * Ajax Request URI. + * @param {string} [customMessage] + * Extra message to print with the Ajax error. + */ + Drupal.Ajax.prototype.error = function (xmlhttprequest, uri, customMessage) { + // Remove the progress element. + if (this.progress.element) { + $(this.progress.element).remove(); + } + if (this.progress.object) { + this.progress.object.stopMonitoring(); + } + // Undo hide. + $(this.wrapper).show(); + // Re-enable the element. + $(this.element).prop('disabled', false); + // Reattach behaviors, if they were detached in beforeSerialize(). + if (this.$form) { + var settings = this.settings || drupalSettings; + Drupal.attachBehaviors(this.$form.get(0), settings); + } + throw new Drupal.AjaxError(xmlhttprequest, uri, customMessage); + }; + + /** + * @typedef {object} Drupal.AjaxCommands~commandDefinition + * + * @prop {string} command + * @prop {string} [method] + * @prop {string} [selector] + * @prop {string} [data] + * @prop {object} [settings] + * @prop {bool} [asterisk] + * @prop {string} [text] + * @prop {string} [title] + * @prop {string} [url] + * @prop {object} [argument] + * @prop {string} [name] + * @prop {string} [value] + * @prop {string} [old] + * @prop {string} [new] + * @prop {bool} [merge] + * @prop {Array} [args] + * + * @see Drupal.AjaxCommands + */ + + /** + * Provide a series of commands that the client will perform. + * + * @constructor + */ + Drupal.AjaxCommands = function () {}; + Drupal.AjaxCommands.prototype = { + + /** + * Command to insert new content into the DOM. + * + * @param {Drupal.Ajax} ajax + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.data + * The data to use with the jQuery method. + * @param {string} [response.method] + * The jQuery DOM manipulation method to be used. + * @param {string} [response.selector] + * A optional jQuery selector string. + * @param {object} [response.settings] + * An optional array of settings that will be used. + * @param {number} [status] + * The XMLHttpRequest status. + */ + insert: function (ajax, response, status) { + // Get information from the response. If it is not there, default to + // our presets. + var $wrapper = response.selector ? $(response.selector) : $(ajax.wrapper); + var method = response.method || ajax.method; + var effect = ajax.getEffect(response); + var settings; + + // We don't know what response.data contains: it might be a string of text + // without HTML, so don't rely on jQuery correctly interpreting + // $(response.data) as new HTML rather than a CSS selector. Also, if + // response.data contains top-level text nodes, they get lost with either + // $(response.data) or $('
').replaceWith(response.data). + var $new_content_wrapped = $('
').html(response.data); + var $new_content = $new_content_wrapped.contents(); + + // For legacy reasons, the effects processing code assumes that + // $new_content consists of a single top-level element. Also, it has not + // been sufficiently tested whether attachBehaviors() can be successfully + // called with a context object that includes top-level text nodes. + // However, to give developers full control of the HTML appearing in the + // page, and to enable Ajax content to be inserted in places where
+ // elements are not allowed (e.g., within , , and + // parents), we check if the new content satisfies the requirement + // of a single top-level element, and only use the container
created + // above when it doesn't. For more information, please see + // https://www.drupal.org/node/736066. + if ($new_content.length !== 1 || $new_content.get(0).nodeType !== 1) { + $new_content = $new_content_wrapped; + } + + // If removing content from the wrapper, detach behaviors first. + switch (method) { + case 'html': + case 'replaceWith': + case 'replaceAll': + case 'empty': + case 'remove': + settings = response.settings || ajax.settings || drupalSettings; + Drupal.detachBehaviors($wrapper.get(0), settings); + } + + // Add the new content to the page. + $wrapper[method]($new_content); + + // Immediately hide the new content if we're using any effects. + if (effect.showEffect !== 'show') { + $new_content.hide(); + } + + // Determine which effect to use and what content will receive the + // effect, then show the new content. + if ($new_content.find('.ajax-new-content').length > 0) { + $new_content.find('.ajax-new-content').hide(); + $new_content.show(); + $new_content.find('.ajax-new-content')[effect.showEffect](effect.showSpeed); + } + else if (effect.showEffect !== 'show') { + $new_content[effect.showEffect](effect.showSpeed); + } + + // Attach all JavaScript behaviors to the new content, if it was + // successfully added to the page, this if statement allows + // `#ajax['wrapper']` to be optional. + if ($new_content.parents('html').length > 0) { + // Apply any settings from the returned JSON if available. + settings = response.settings || ajax.settings || drupalSettings; + Drupal.attachBehaviors($new_content.get(0), settings); + } + }, + + /** + * Command to remove a chunk from the page. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.selector + * A jQuery selector string. + * @param {object} [response.settings] + * An optional array of settings that will be used. + * @param {number} [status] + * The XMLHttpRequest status. + */ + remove: function (ajax, response, status) { + var settings = response.settings || ajax.settings || drupalSettings; + $(response.selector).each(function () { + Drupal.detachBehaviors(this, settings); + }) + .remove(); + }, + + /** + * Command to mark a chunk changed. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The JSON response object from the Ajax request. + * @param {string} response.selector + * A jQuery selector string. + * @param {bool} [response.asterisk] + * An optional CSS selector. If specified, an asterisk will be + * appended to the HTML inside the provided selector. + * @param {number} [status] + * The request status. + */ + changed: function (ajax, response, status) { + var $element = $(response.selector); + if (!$element.hasClass('ajax-changed')) { + $element.addClass('ajax-changed'); + if (response.asterisk) { + $element.find(response.asterisk).append(' * '); + } + } + }, + + /** + * Command to provide an alert. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The JSON response from the Ajax request. + * @param {string} response.text + * The text that will be displayed in an alert dialog. + * @param {number} [status] + * The XMLHttpRequest status. + */ + alert: function (ajax, response, status) { + window.alert(response.text, response.title); + }, + + /** + * Command to set the window.location, redirecting the browser. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.url + * The URL to redirect to. + * @param {number} [status] + * The XMLHttpRequest status. + */ + redirect: function (ajax, response, status) { + window.location = response.url; + }, + + /** + * Command to provide the jQuery css() function. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.selector + * A jQuery selector string. + * @param {object} response.argument + * An array of key/value pairs to set in the CSS for the selector. + * @param {number} [status] + * The XMLHttpRequest status. + */ + css: function (ajax, response, status) { + $(response.selector).css(response.argument); + }, + + /** + * Command to set the settings used for other commands in this response. + * + * This method will also remove expired `drupalSettings.ajax` settings. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {bool} response.merge + * Determines whether the additional settings should be merged to the + * global settings. + * @param {object} response.settings + * Contains additional settings to add to the global settings. + * @param {number} [status] + * The XMLHttpRequest status. + */ + settings: function (ajax, response, status) { + var ajaxSettings = drupalSettings.ajax; + + // Clean up drupalSettings.ajax. + if (ajaxSettings) { + Drupal.ajax.expired().forEach(function (instance) { + // If the Ajax object has been created through drupalSettings.ajax + // it will have a selector. When there is no selector the object + // has been initialized with a special class name picked up by the + // Ajax behavior. + + if (instance.selector) { + var selector = instance.selector.replace('#', ''); + if (selector in ajaxSettings) { + delete ajaxSettings[selector]; + } + } + }); + } + + if (response.merge) { + $.extend(true, drupalSettings, response.settings); + } + else { + ajax.settings = response.settings; + } + }, + + /** + * Command to attach data using jQuery's data API. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.name + * The name or key (in the key value pair) of the data attached to this + * selector. + * @param {string} response.selector + * A jQuery selector string. + * @param {string|object} response.value + * The value of to be attached. + * @param {number} [status] + * The XMLHttpRequest status. + */ + data: function (ajax, response, status) { + $(response.selector).data(response.name, response.value); + }, + + /** + * Command to apply a jQuery method. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {Array} response.args + * An array of arguments to the jQuery method, if any. + * @param {string} response.method + * The jQuery method to invoke. + * @param {string} response.selector + * A jQuery selector string. + * @param {number} [status] + * The XMLHttpRequest status. + */ + invoke: function (ajax, response, status) { + var $element = $(response.selector); + $element[response.method].apply($element, response.args); + }, + + /** + * Command to restripe a table. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.selector + * A jQuery selector string. + * @param {number} [status] + * The XMLHttpRequest status. + */ + restripe: function (ajax, response, status) { + // :even and :odd are reversed because jQuery counts from 0 and + // we count from 1, so we're out of sync. + // Match immediate children of the parent element to allow nesting. + $(response.selector).find('> tbody > tr:visible, > tr:visible') + .removeClass('odd even') + .filter(':even').addClass('odd').end() + .filter(':odd').addClass('even'); + }, + + /** + * Command to update a form's build ID. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.old + * The old form build ID. + * @param {string} response.new + * The new form build ID. + * @param {number} [status] + * The XMLHttpRequest status. + */ + update_build_id: function (ajax, response, status) { + $('input[name="form_build_id"][value="' + response.old + '"]').val(response.new); + }, + + /** + * Command to add css. + * + * Uses the proprietary addImport method if available as browsers which + * support that method ignore @import statements in dynamically added + * stylesheets. + * + * @param {Drupal.Ajax} [ajax] + * {@link Drupal.Ajax} object created by {@link Drupal.ajax}. + * @param {object} response + * The response from the Ajax request. + * @param {string} response.data + * A string that contains the styles to be added. + * @param {number} [status] + * The XMLHttpRequest status. + */ + add_css: function (ajax, response, status) { + // Add the styles in the normal way. + $('head').prepend(response.data); + // Add imports in the styles using the addImport method if available. + var match; + var importMatch = /^@import url\("(.*)"\);$/igm; + if (document.styleSheets[0].addImport && importMatch.test(response.data)) { + importMatch.lastIndex = 0; + do { + match = importMatch.exec(response.data); + document.styleSheets[0].addImport(match[1]); + } while (match); + } + } + }; + +})(jQuery, window, Drupal, drupalSettings); diff --git a/core/misc/announce.es6.js b/core/misc/announce.es6.js new file mode 100644 index 0000000000..acf850a641 --- /dev/null +++ b/core/misc/announce.es6.js @@ -0,0 +1,120 @@ +/** + * @file + * Adds an HTML element and method to trigger audio UAs to read system messages. + * + * Use {@link Drupal.announce} to indicate to screen reader users that an + * element on the page has changed state. For instance, if clicking a link + * loads 10 more items into a list, one might announce the change like this. + * + * @example + * $('#search-list') + * .on('itemInsert', function (event, data) { + * // Insert the new items. + * $(data.container.el).append(data.items.el); + * // Announce the change to the page contents. + * Drupal.announce(Drupal.t('@count items added to @container', + * {'@count': data.items.length, '@container': data.container.title} + * )); + * }); + */ + +(function (Drupal, debounce) { + + 'use strict'; + + var liveElement; + var announcements = []; + + /** + * Builds a div element with the aria-live attribute and add it to the DOM. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the behavior for drupalAnnouce. + */ + Drupal.behaviors.drupalAnnounce = { + attach: function (context) { + // Create only one aria-live element. + if (!liveElement) { + liveElement = document.createElement('div'); + liveElement.id = 'drupal-live-announce'; + liveElement.className = 'visually-hidden'; + liveElement.setAttribute('aria-live', 'polite'); + liveElement.setAttribute('aria-busy', 'false'); + document.body.appendChild(liveElement); + } + } + }; + + /** + * Concatenates announcements to a single string; appends to the live region. + */ + function announce() { + var text = []; + var priority = 'polite'; + var announcement; + + // Create an array of announcement strings to be joined and appended to the + // aria live region. + var il = announcements.length; + for (var i = 0; i < il; i++) { + announcement = announcements.pop(); + text.unshift(announcement.text); + // If any of the announcements has a priority of assertive then the group + // of joined announcements will have this priority. + if (announcement.priority === 'assertive') { + priority = 'assertive'; + } + } + + if (text.length) { + // Clear the liveElement so that repeated strings will be read. + liveElement.innerHTML = ''; + // Set the busy state to true until the node changes are complete. + liveElement.setAttribute('aria-busy', 'true'); + // Set the priority to assertive, or default to polite. + liveElement.setAttribute('aria-live', priority); + // Print the text to the live region. Text should be run through + // Drupal.t() before being passed to Drupal.announce(). + liveElement.innerHTML = text.join('\n'); + // The live text area is updated. Allow the AT to announce the text. + liveElement.setAttribute('aria-busy', 'false'); + } + } + + /** + * Triggers audio UAs to read the supplied text. + * + * The aria-live region will only read the text that currently populates its + * text node. Replacing text quickly in rapid calls to announce results in + * only the text from the most recent call to {@link Drupal.announce} being + * read. By wrapping the call to announce in a debounce function, we allow for + * time for multiple calls to {@link Drupal.announce} to queue up their + * messages. These messages are then joined and append to the aria-live region + * as one text node. + * + * @param {string} text + * A string to be read by the UA. + * @param {string} [priority='polite'] + * A string to indicate the priority of the message. Can be either + * 'polite' or 'assertive'. + * + * @return {function} + * The return of the call to debounce. + * + * @see http://www.w3.org/WAI/PF/aria-practices/#liveprops + */ + Drupal.announce = function (text, priority) { + // Save the text and priority into a closure variable. Multiple simultaneous + // announcements will be concatenated and read in sequence. + announcements.push({ + text: text, + priority: priority + }); + // Immediately invoke the function that debounce returns. 200 ms is right at + // the cusp where humans notice a pause, so we will wait + // at most this much time before the set of queued announcements is read. + return (debounce(announce, 200)()); + }; +}(Drupal, Drupal.debounce)); diff --git a/core/misc/autocomplete.es6.js b/core/misc/autocomplete.es6.js new file mode 100644 index 0000000000..5a1c156d0b --- /dev/null +++ b/core/misc/autocomplete.es6.js @@ -0,0 +1,288 @@ +/** + * @file + * Autocomplete based on jQuery UI. + */ + +(function ($, Drupal) { + + 'use strict'; + + var autocomplete; + + /** + * Helper splitting terms from the autocomplete value. + * + * @function Drupal.autocomplete.splitValues + * + * @param {string} value + * The value being entered by the user. + * + * @return {Array} + * Array of values, split by comma. + */ + function autocompleteSplitValues(value) { + // We will match the value against comma-separated terms. + var result = []; + var quote = false; + var current = ''; + var valueLength = value.length; + var character; + + for (var i = 0; i < valueLength; i++) { + character = value.charAt(i); + if (character === '"') { + current += character; + quote = !quote; + } + else if (character === ',' && !quote) { + result.push(current.trim()); + current = ''; + } + else { + current += character; + } + } + if (value.length > 0) { + result.push($.trim(current)); + } + + return result; + } + + /** + * Returns the last value of an multi-value textfield. + * + * @function Drupal.autocomplete.extractLastTerm + * + * @param {string} terms + * The value of the field. + * + * @return {string} + * The last value of the input field. + */ + function extractLastTerm(terms) { + return autocomplete.splitValues(terms).pop(); + } + + /** + * The search handler is called before a search is performed. + * + * @function Drupal.autocomplete.options.search + * + * @param {object} event + * The event triggered. + * + * @return {bool} + * Whether to perform a search or not. + */ + function searchHandler(event) { + var options = autocomplete.options; + + if (options.isComposing) { + return false; + } + + var term = autocomplete.extractLastTerm(event.target.value); + // Abort search if the first character is in firstCharacterBlacklist. + if (term.length > 0 && options.firstCharacterBlacklist.indexOf(term[0]) !== -1) { + return false; + } + // Only search when the term is at least the minimum length. + return term.length >= options.minLength; + } + + /** + * JQuery UI autocomplete source callback. + * + * @param {object} request + * The request object. + * @param {function} response + * The function to call with the response. + */ + function sourceData(request, response) { + var elementId = this.element.attr('id'); + + if (!(elementId in autocomplete.cache)) { + autocomplete.cache[elementId] = {}; + } + + /** + * Filter through the suggestions removing all terms already tagged and + * display the available terms to the user. + * + * @param {object} suggestions + * Suggestions returned by the server. + */ + function showSuggestions(suggestions) { + var tagged = autocomplete.splitValues(request.term); + var il = tagged.length; + for (var i = 0; i < il; i++) { + var index = suggestions.indexOf(tagged[i]); + if (index >= 0) { + suggestions.splice(index, 1); + } + } + response(suggestions); + } + + /** + * Transforms the data object into an array and update autocomplete results. + * + * @param {object} data + * The data sent back from the server. + */ + function sourceCallbackHandler(data) { + autocomplete.cache[elementId][term] = data; + + // Send the new string array of terms to the jQuery UI list. + showSuggestions(data); + } + + // Get the desired term and construct the autocomplete URL for it. + var term = autocomplete.extractLastTerm(request.term); + + // Check if the term is already cached. + if (autocomplete.cache[elementId].hasOwnProperty(term)) { + showSuggestions(autocomplete.cache[elementId][term]); + } + else { + var options = $.extend({success: sourceCallbackHandler, data: {q: term}}, autocomplete.ajax); + $.ajax(this.element.attr('data-autocomplete-path'), options); + } + } + + /** + * Handles an autocompletefocus event. + * + * @return {bool} + * Always returns false. + */ + function focusHandler() { + return false; + } + + /** + * Handles an autocompleteselect event. + * + * @param {jQuery.Event} event + * The event triggered. + * @param {object} ui + * The jQuery UI settings object. + * + * @return {bool} + * Returns false to indicate the event status. + */ + function selectHandler(event, ui) { + var terms = autocomplete.splitValues(event.target.value); + // Remove the current input. + terms.pop(); + // Add the selected item. + if (ui.item.value.search(',') > 0) { + terms.push('"' + ui.item.value + '"'); + } + else { + terms.push(ui.item.value); + } + event.target.value = terms.join(', '); + // Return false to tell jQuery UI that we've filled in the value already. + return false; + } + + /** + * Override jQuery UI _renderItem function to output HTML by default. + * + * @param {jQuery} ul + * jQuery collection of the ul element. + * @param {object} item + * The list item to append. + * + * @return {jQuery} + * jQuery collection of the ul element. + */ + function renderItem(ul, item) { + return $('
  • ') + .append($('').html(item.label)) + .appendTo(ul); + } + + /** + * Attaches the autocomplete behavior to all required fields. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the autocomplete behaviors. + * @prop {Drupal~behaviorDetach} detach + * Detaches the autocomplete behaviors. + */ + Drupal.behaviors.autocomplete = { + attach: function (context) { + // Act on textfields with the "form-autocomplete" class. + var $autocomplete = $(context).find('input.form-autocomplete').once('autocomplete'); + if ($autocomplete.length) { + // Allow options to be overriden per instance. + var blacklist = $autocomplete.attr('data-autocomplete-first-character-blacklist'); + $.extend(autocomplete.options, { + firstCharacterBlacklist: (blacklist) ? blacklist : '' + }); + // Use jQuery UI Autocomplete on the textfield. + $autocomplete.autocomplete(autocomplete.options) + .each(function () { + $(this).data('ui-autocomplete')._renderItem = autocomplete.options.renderItem; + }); + + // Use CompositionEvent to handle IME inputs. It requests remote server on "compositionend" event only. + $autocomplete.on('compositionstart.autocomplete', function () { + autocomplete.options.isComposing = true; + }); + $autocomplete.on('compositionend.autocomplete', function () { + autocomplete.options.isComposing = false; + }); + } + }, + detach: function (context, settings, trigger) { + if (trigger === 'unload') { + $(context).find('input.form-autocomplete') + .removeOnce('autocomplete') + .autocomplete('destroy'); + } + } + }; + + /** + * Autocomplete object implementation. + * + * @namespace Drupal.autocomplete + */ + autocomplete = { + cache: {}, + // Exposes options to allow overriding by contrib. + splitValues: autocompleteSplitValues, + extractLastTerm: extractLastTerm, + // jQuery UI autocomplete options. + + /** + * JQuery UI option object. + * + * @name Drupal.autocomplete.options + */ + options: { + source: sourceData, + focus: focusHandler, + search: searchHandler, + select: selectHandler, + renderItem: renderItem, + minLength: 1, + // Custom options, used by Drupal.autocomplete. + firstCharacterBlacklist: '', + // Custom options, indicate IME usage status. + isComposing: false + }, + ajax: { + dataType: 'json' + } + }; + + Drupal.autocomplete = autocomplete; + +})(jQuery, Drupal); diff --git a/core/misc/batch.es6.js b/core/misc/batch.es6.js new file mode 100644 index 0000000000..411badba42 --- /dev/null +++ b/core/misc/batch.es6.js @@ -0,0 +1,46 @@ +/** + * @file + * Drupal's batch API. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Attaches the batch behavior to progress bars. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.batch = { + attach: function (context, settings) { + var batch = settings.batch; + var $progress = $('[data-drupal-progress]').once('batch'); + var progressBar; + + // Success: redirect to the summary. + function updateCallback(progress, status, pb) { + if (progress === '100') { + pb.stopMonitoring(); + window.location = batch.uri + '&op=finished'; + } + } + + function errorCallback(pb) { + $progress.prepend($('

    ').html(batch.errorMessage)); + $('#wait').hide(); + } + + if ($progress.length) { + progressBar = new Drupal.ProgressBar('updateprogress', updateCallback, 'POST', errorCallback); + progressBar.setProgress(-1, batch.initMessage); + progressBar.startMonitoring(batch.uri + '&op=do', 10); + // Remove HTML from no-js progress bar. + $progress.empty(); + // Append the JS progressbar element. + $progress.append(progressBar.element); + } + } + }; + +})(jQuery, Drupal); diff --git a/core/misc/collapse.es6.js b/core/misc/collapse.es6.js new file mode 100644 index 0000000000..767325ec1a --- /dev/null +++ b/core/misc/collapse.es6.js @@ -0,0 +1,146 @@ +/** + * @file + * Polyfill for HTML5 details elements. + */ + +(function ($, Modernizr, Drupal) { + + 'use strict'; + + /** + * The collapsible details object represents a single details element. + * + * @constructor Drupal.CollapsibleDetails + * + * @param {HTMLElement} node + * The details element. + */ + function CollapsibleDetails(node) { + this.$node = $(node); + this.$node.data('details', this); + // Expand details if there are errors inside, or if it contains an + // element that is targeted by the URI fragment identifier. + var anchor = location.hash && location.hash !== '#' ? ', ' + location.hash : ''; + if (this.$node.find('.error' + anchor).length) { + this.$node.attr('open', true); + } + // Initialize and setup the summary, + this.setupSummary(); + // Initialize and setup the legend. + this.setupLegend(); + } + + $.extend(CollapsibleDetails, /** @lends Drupal.CollapsibleDetails */{ + + /** + * Holds references to instantiated CollapsibleDetails objects. + * + * @type {Array.} + */ + instances: [] + }); + + $.extend(CollapsibleDetails.prototype, /** @lends Drupal.CollapsibleDetails# */{ + + /** + * Initialize and setup summary events and markup. + * + * @fires event:summaryUpdated + * + * @listens event:summaryUpdated + */ + setupSummary: function () { + this.$summary = $(''); + this.$node + .on('summaryUpdated', $.proxy(this.onSummaryUpdated, this)) + .trigger('summaryUpdated'); + }, + + /** + * Initialize and setup legend markup. + */ + setupLegend: function () { + // Turn the summary into a clickable link. + var $legend = this.$node.find('> summary'); + + $('') + .append(this.$node.attr('open') ? Drupal.t('Hide') : Drupal.t('Show')) + .prependTo($legend) + .after(document.createTextNode(' ')); + + // .wrapInner() does not retain bound events. + $('
    ') + .attr('href', '#' + this.$node.attr('id')) + .prepend($legend.contents()) + .appendTo($legend); + + $legend + .append(this.$summary) + .on('click', $.proxy(this.onLegendClick, this)); + }, + + /** + * Handle legend clicks. + * + * @param {jQuery.Event} e + * The event triggered. + */ + onLegendClick: function (e) { + this.toggle(); + e.preventDefault(); + }, + + /** + * Update summary. + */ + onSummaryUpdated: function () { + var text = $.trim(this.$node.drupalGetSummary()); + this.$summary.html(text ? ' (' + text + ')' : ''); + }, + + /** + * Toggle the visibility of a details element using smooth animations. + */ + toggle: function () { + var isOpen = !!this.$node.attr('open'); + var $summaryPrefix = this.$node.find('> summary span.details-summary-prefix'); + if (isOpen) { + $summaryPrefix.html(Drupal.t('Show')); + } + else { + $summaryPrefix.html(Drupal.t('Hide')); + } + // Delay setting the attribute to emulate chrome behavior and make + // details-aria.js work as expected with this polyfill. + setTimeout(function () { + this.$node.attr('open', !isOpen); + }.bind(this), 0); + } + }); + + /** + * Polyfill HTML5 details element. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behavior for the details element. + */ + Drupal.behaviors.collapse = { + attach: function (context) { + if (Modernizr.details) { + return; + } + var $collapsibleDetails = $(context).find('details').once('collapse').addClass('collapse-processed'); + if ($collapsibleDetails.length) { + for (var i = 0; i < $collapsibleDetails.length; i++) { + CollapsibleDetails.instances.push(new CollapsibleDetails($collapsibleDetails[i])); + } + } + } + }; + + // Expose constructor in the public space. + Drupal.CollapsibleDetails = CollapsibleDetails; + +})(jQuery, Modernizr, Drupal); diff --git a/core/misc/date.es6.js b/core/misc/date.es6.js new file mode 100644 index 0000000000..8b6b71cb1d --- /dev/null +++ b/core/misc/date.es6.js @@ -0,0 +1,56 @@ +/** + * @file + * Polyfill for HTML5 date input. + */ + +(function ($, Modernizr, Drupal) { + + 'use strict'; + + /** + * Attach datepicker fallback on date elements. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the behavior. Accepts in `settings.date` an object listing + * elements to process, keyed by the HTML ID of the form element containing + * the human-readable value. Each element is an datepicker settings object. + * @prop {Drupal~behaviorDetach} detach + * Detach the behavior destroying datepickers on effected elements. + */ + Drupal.behaviors.date = { + attach: function (context, settings) { + var $context = $(context); + // Skip if date are supported by the browser. + if (Modernizr.inputtypes.date === true) { + return; + } + $context.find('input[data-drupal-date-format]').once('datePicker').each(function () { + var $input = $(this); + var datepickerSettings = {}; + var dateFormat = $input.data('drupalDateFormat'); + // The date format is saved in PHP style, we need to convert to jQuery + // datepicker. + datepickerSettings.dateFormat = dateFormat + .replace('Y', 'yy') + .replace('m', 'mm') + .replace('d', 'dd'); + // Add min and max date if set on the input. + if ($input.attr('min')) { + datepickerSettings.minDate = $input.attr('min'); + } + if ($input.attr('max')) { + datepickerSettings.maxDate = $input.attr('max'); + } + $input.datepicker(datepickerSettings); + }); + }, + detach: function (context, settings, trigger) { + if (trigger === 'unload') { + $(context).find('input[data-drupal-date-format]').findOnce('datePicker').datepicker('destroy'); + } + } + }; + +})(jQuery, Modernizr, Drupal); diff --git a/core/misc/debounce.es6.js b/core/misc/debounce.es6.js new file mode 100644 index 0000000000..995e3d7135 --- /dev/null +++ b/core/misc/debounce.es6.js @@ -0,0 +1,52 @@ +/** + * @file + * Adapted from underscore.js with the addition Drupal namespace. + */ + +/** + * Limits the invocations of a function in a given time frame. + * + * The debounce function wrapper should be used sparingly. One clear use case + * is limiting the invocation of a callback attached to the window resize event. + * + * Before using the debounce function wrapper, consider first whether the + * callback could be attached to an event that fires less frequently or if the + * function can be written in such a way that it is only invoked under specific + * conditions. + * + * @param {function} func + * The function to be invoked. + * @param {number} wait + * The time period within which the callback function should only be + * invoked once. For example if the wait period is 250ms, then the callback + * will only be called at most 4 times per second. + * @param {bool} immediate + * Whether we wait at the beginning or end to execute the function. + * + * @return {function} + * The debounced function. + */ +Drupal.debounce = function (func, wait, immediate) { + + 'use strict'; + + var timeout; + var result; + return function () { + var context = this; + var args = arguments; + var later = function () { + timeout = null; + if (!immediate) { + result = func.apply(context, args); + } + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + result = func.apply(context, args); + } + return result; + }; +}; diff --git a/core/misc/details-aria.es6.js b/core/misc/details-aria.es6.js new file mode 100644 index 0000000000..d341422351 --- /dev/null +++ b/core/misc/details-aria.es6.js @@ -0,0 +1,29 @@ +/** + * @file + * Add aria attribute handling for details and summary elements. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Handles `aria-expanded` and `aria-pressed` attributes on details elements. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.detailsAria = { + attach: function () { + $('body').once('detailsAria').on('click.detailsAria', 'summary', function (event) { + var $summary = $(event.currentTarget); + var open = $(event.currentTarget.parentNode).attr('open') === 'open' ? 'false' : 'true'; + + $summary.attr({ + 'aria-expanded': open, + 'aria-pressed': open + }); + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/misc/dialog/dialog.ajax.es6.js b/core/misc/dialog/dialog.ajax.es6.js new file mode 100644 index 0000000000..3f1b0c2efd --- /dev/null +++ b/core/misc/dialog/dialog.ajax.es6.js @@ -0,0 +1,246 @@ +/** + * @file + * Extends the Drupal AJAX functionality to integrate the dialog API. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Initialize dialogs for Ajax purposes. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the behaviors for dialog ajax functionality. + */ + Drupal.behaviors.dialog = { + attach: function (context, settings) { + var $context = $(context); + + // Provide a known 'drupal-modal' DOM element for Drupal-based modal + // dialogs. Non-modal dialogs are responsible for creating their own + // elements, since there can be multiple non-modal dialogs at a time. + if (!$('#drupal-modal').length) { + // Add 'ui-front' jQuery UI class so jQuery UI widgets like autocomplete + // sit on top of dialogs. For more information see + // http://api.jqueryui.com/theming/stacking-elements/. + $('
    ').hide().appendTo('body'); + } + + // Special behaviors specific when attaching content within a dialog. + // These behaviors usually fire after a validation error inside a dialog. + var $dialog = $context.closest('.ui-dialog-content'); + if ($dialog.length) { + // Remove and replace the dialog buttons with those from the new form. + if ($dialog.dialog('option', 'drupalAutoButtons')) { + // Trigger an event to detect/sync changes to buttons. + $dialog.trigger('dialogButtonsChange'); + } + + // Force focus on the modal when the behavior is run. + $dialog.dialog('widget').trigger('focus'); + } + + var originalClose = settings.dialog.close; + // Overwrite the close method to remove the dialog on closing. + settings.dialog.close = function (event) { + originalClose.apply(settings.dialog, arguments); + $(event.target).remove(); + }; + }, + + /** + * Scan a dialog for any primary buttons and move them to the button area. + * + * @param {jQuery} $dialog + * An jQuery object containing the element that is the dialog target. + * + * @return {Array} + * An array of buttons that need to be added to the button area. + */ + prepareDialogButtons: function ($dialog) { + var buttons = []; + var $buttons = $dialog.find('.form-actions input[type=submit], .form-actions a.button'); + $buttons.each(function () { + // Hidden form buttons need special attention. For browser consistency, + // the button needs to be "visible" in order to have the enter key fire + // the form submit event. So instead of a simple "hide" or + // "display: none", we set its dimensions to zero. + // See http://mattsnider.com/how-forms-submit-when-pressing-enter/ + var $originalButton = $(this).css({ + display: 'block', + width: 0, + height: 0, + padding: 0, + border: 0, + overflow: 'hidden' + }); + buttons.push({ + text: $originalButton.html() || $originalButton.attr('value'), + class: $originalButton.attr('class'), + click: function (e) { + // If the original button is an anchor tag, triggering the "click" + // event will not simulate a click. Use the click method instead. + if ($originalButton.is('a')) { + $originalButton[0].click(); + } + else { + $originalButton.trigger('mousedown').trigger('mouseup').trigger('click'); + e.preventDefault(); + } + } + }); + }); + return buttons; + } + }; + + /** + * Command to open a dialog. + * + * @param {Drupal.Ajax} ajax + * The Drupal Ajax object. + * @param {object} response + * Object holding the server response. + * @param {number} [status] + * The HTTP status code. + * + * @return {bool|undefined} + * Returns false if there was no selector property in the response object. + */ + Drupal.AjaxCommands.prototype.openDialog = function (ajax, response, status) { + if (!response.selector) { + return false; + } + var $dialog = $(response.selector); + if (!$dialog.length) { + // Create the element if needed. + $dialog = $('
    ').appendTo('body'); + } + // Set up the wrapper, if there isn't one. + if (!ajax.wrapper) { + ajax.wrapper = $dialog.attr('id'); + } + + // Use the ajax.js insert command to populate the dialog contents. + response.command = 'insert'; + response.method = 'html'; + ajax.commands.insert(ajax, response, status); + + // Move the buttons to the jQuery UI dialog buttons area. + if (!response.dialogOptions.buttons) { + response.dialogOptions.drupalAutoButtons = true; + response.dialogOptions.buttons = Drupal.behaviors.dialog.prepareDialogButtons($dialog); + } + + // Bind dialogButtonsChange. + $dialog.on('dialogButtonsChange', function () { + var buttons = Drupal.behaviors.dialog.prepareDialogButtons($dialog); + $dialog.dialog('option', 'buttons', buttons); + }); + + // Open the dialog itself. + response.dialogOptions = response.dialogOptions || {}; + var dialog = Drupal.dialog($dialog.get(0), response.dialogOptions); + if (response.dialogOptions.modal) { + dialog.showModal(); + } + else { + dialog.show(); + } + + // Add the standard Drupal class for buttons for style consistency. + $dialog.parent().find('.ui-dialog-buttonset').addClass('form-actions'); + }; + + /** + * Command to close a dialog. + * + * If no selector is given, it defaults to trying to close the modal. + * + * @param {Drupal.Ajax} [ajax] + * The ajax object. + * @param {object} response + * Object holding the server response. + * @param {string} response.selector + * The selector of the dialog. + * @param {bool} response.persist + * Whether to persist the dialog element or not. + * @param {number} [status] + * The HTTP status code. + */ + Drupal.AjaxCommands.prototype.closeDialog = function (ajax, response, status) { + var $dialog = $(response.selector); + if ($dialog.length) { + Drupal.dialog($dialog.get(0)).close(); + if (!response.persist) { + $dialog.remove(); + } + } + + // Unbind dialogButtonsChange. + $dialog.off('dialogButtonsChange'); + }; + + /** + * Command to set a dialog property. + * + * JQuery UI specific way of setting dialog options. + * + * @param {Drupal.Ajax} [ajax] + * The Drupal Ajax object. + * @param {object} response + * Object holding the server response. + * @param {string} response.selector + * Selector for the dialog element. + * @param {string} response.optionsName + * Name of a key to set. + * @param {string} response.optionValue + * Value to set. + * @param {number} [status] + * The HTTP status code. + */ + Drupal.AjaxCommands.prototype.setDialogOption = function (ajax, response, status) { + var $dialog = $(response.selector); + if ($dialog.length) { + $dialog.dialog('option', response.optionName, response.optionValue); + } + }; + + /** + * Binds a listener on dialog creation to handle the cancel link. + * + * @param {jQuery.Event} e + * The event triggered. + * @param {Drupal.dialog~dialogDefinition} dialog + * The dialog instance. + * @param {jQuery} $element + * The jQuery collection of the dialog element. + * @param {object} [settings] + * Dialog settings. + */ + $(window).on('dialog:aftercreate', function (e, dialog, $element, settings) { + $element.on('click.dialog', '.dialog-cancel', function (e) { + dialog.close('cancel'); + e.preventDefault(); + e.stopPropagation(); + }); + }); + + /** + * Removes all 'dialog' listeners. + * + * @param {jQuery.Event} e + * The event triggered. + * @param {Drupal.dialog~dialogDefinition} dialog + * The dialog instance. + * @param {jQuery} $element + * jQuery collection of the dialog element. + */ + $(window).on('dialog:beforeclose', function (e, dialog, $element) { + $element.off('.dialog'); + }); + +})(jQuery, Drupal); diff --git a/core/misc/dialog/dialog.es6.js b/core/misc/dialog/dialog.es6.js new file mode 100644 index 0000000000..ea1e52c8dc --- /dev/null +++ b/core/misc/dialog/dialog.es6.js @@ -0,0 +1,100 @@ +/** + * @file + * Dialog API inspired by HTML5 dialog element. + * + * @see http://www.whatwg.org/specs/web-apps/current-work/multipage/commands.html#the-dialog-element + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Default dialog options. + * + * @type {object} + * + * @prop {bool} [autoOpen=true] + * @prop {string} [dialogClass=''] + * @prop {string} [buttonClass='button'] + * @prop {string} [buttonPrimaryClass='button--primary'] + * @prop {function} close + */ + drupalSettings.dialog = { + autoOpen: true, + dialogClass: '', + // Drupal-specific extensions: see dialog.jquery-ui.js. + buttonClass: 'button', + buttonPrimaryClass: 'button--primary', + // When using this API directly (when generating dialogs on the client + // side), you may want to override this method and do + // `jQuery(event.target).remove()` as well, to remove the dialog on + // closing. + close: function (event) { + Drupal.dialog(event.target).close(); + Drupal.detachBehaviors(event.target, null, 'unload'); + } + }; + + /** + * @typedef {object} Drupal.dialog~dialogDefinition + * + * @prop {boolean} open + * Is the dialog open or not. + * @prop {*} returnValue + * Return value of the dialog. + * @prop {function} show + * Method to display the dialog on the page. + * @prop {function} showModal + * Method to display the dialog as a modal on the page. + * @prop {function} close + * Method to hide the dialog from the page. + */ + + /** + * Polyfill HTML5 dialog element with jQueryUI. + * + * @param {HTMLElement} element + * The element that holds the dialog. + * @param {object} options + * jQuery UI options to be passed to the dialog. + * + * @return {Drupal.dialog~dialogDefinition} + * The dialog instance. + */ + Drupal.dialog = function (element, options) { + var undef; + var $element = $(element); + var dialog = { + open: false, + returnValue: undef, + show: function () { + openDialog({modal: false}); + }, + showModal: function () { + openDialog({modal: true}); + }, + close: closeDialog + }; + + function openDialog(settings) { + settings = $.extend({}, drupalSettings.dialog, options, settings); + // Trigger a global event to allow scripts to bind events to the dialog. + $(window).trigger('dialog:beforecreate', [dialog, $element, settings]); + $element.dialog(settings); + dialog.open = true; + $(window).trigger('dialog:aftercreate', [dialog, $element, settings]); + } + + function closeDialog(value) { + $(window).trigger('dialog:beforeclose', [dialog, $element]); + $element.dialog('close'); + dialog.returnValue = value; + dialog.open = false; + $(window).trigger('dialog:afterclose', [dialog, $element]); + } + + return dialog; + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/misc/dialog/dialog.jquery-ui.es6.js b/core/misc/dialog/dialog.jquery-ui.es6.js new file mode 100644 index 0000000000..2526e30953 --- /dev/null +++ b/core/misc/dialog/dialog.jquery-ui.es6.js @@ -0,0 +1,36 @@ +/** + * @file + * Adds default classes to buttons for styling purposes. + */ + +(function ($) { + + 'use strict'; + + $.widget('ui.dialog', $.ui.dialog, { + options: { + buttonClass: 'button', + buttonPrimaryClass: 'button--primary' + }, + _createButtons: function () { + var opts = this.options; + var primaryIndex; + var $buttons; + var index; + var il = opts.buttons.length; + for (index = 0; index < il; index++) { + if (opts.buttons[index].primary && opts.buttons[index].primary === true) { + primaryIndex = index; + delete opts.buttons[index].primary; + break; + } + } + this._super(); + $buttons = this.uiButtonSet.children().addClass(opts.buttonClass); + if (typeof primaryIndex !== 'undefined') { + $buttons.eq(index).addClass(opts.buttonPrimaryClass); + } + } + }); + +})(jQuery); diff --git a/core/misc/dialog/dialog.position.es6.js b/core/misc/dialog/dialog.position.es6.js new file mode 100644 index 0000000000..e3c058f844 --- /dev/null +++ b/core/misc/dialog/dialog.position.es6.js @@ -0,0 +1,112 @@ +/** + * @file + * Positioning extensions for dialogs. + */ + +/** + * Triggers when content inside a dialog changes. + * + * @event dialogContentResize + */ + +(function ($, Drupal, drupalSettings, debounce, displace) { + + 'use strict'; + + // autoResize option will turn off resizable and draggable. + drupalSettings.dialog = $.extend({autoResize: true, maxHeight: '95%'}, drupalSettings.dialog); + + /** + * Resets the current options for positioning. + * + * This is used as a window resize and scroll callback to reposition the + * jQuery UI dialog. Although not a built-in jQuery UI option, this can + * be disabled by setting autoResize: false in the options array when creating + * a new {@link Drupal.dialog}. + * + * @function Drupal.dialog~resetSize + * + * @param {jQuery.Event} event + * The event triggered. + * + * @fires event:dialogContentResize + */ + function resetSize(event) { + var positionOptions = ['width', 'height', 'minWidth', 'minHeight', 'maxHeight', 'maxWidth', 'position']; + var adjustedOptions = {}; + var windowHeight = $(window).height(); + var option; + var optionValue; + var adjustedValue; + for (var n = 0; n < positionOptions.length; n++) { + option = positionOptions[n]; + optionValue = event.data.settings[option]; + if (optionValue) { + // jQuery UI does not support percentages on heights, convert to pixels. + if (typeof optionValue === 'string' && /%$/.test(optionValue) && /height/i.test(option)) { + // Take offsets in account. + windowHeight -= displace.offsets.top + displace.offsets.bottom; + adjustedValue = parseInt(0.01 * parseInt(optionValue, 10) * windowHeight, 10); + // Don't force the dialog to be bigger vertically than needed. + if (option === 'height' && event.data.$element.parent().outerHeight() < adjustedValue) { + adjustedValue = 'auto'; + } + adjustedOptions[option] = adjustedValue; + } + } + } + // Offset the dialog center to be at the center of Drupal.displace.offsets. + if (!event.data.settings.modal) { + adjustedOptions = resetPosition(adjustedOptions); + } + event.data.$element + .dialog('option', adjustedOptions) + .trigger('dialogContentResize'); + } + + /** + * Position the dialog's center at the center of displace.offsets boundaries. + * + * @function Drupal.dialog~resetPosition + * + * @param {object} options + * Options object. + * + * @return {object} + * Altered options object. + */ + function resetPosition(options) { + var offsets = displace.offsets; + var left = offsets.left - offsets.right; + var top = offsets.top - offsets.bottom; + + var leftString = (left > 0 ? '+' : '-') + Math.abs(Math.round(left / 2)) + 'px'; + var topString = (top > 0 ? '+' : '-') + Math.abs(Math.round(top / 2)) + 'px'; + options.position = { + my: 'center' + (left !== 0 ? leftString : '') + ' center' + (top !== 0 ? topString : ''), + of: window + }; + return options; + } + + $(window).on({ + 'dialog:aftercreate': function (event, dialog, $element, settings) { + var autoResize = debounce(resetSize, 20); + var eventData = {settings: settings, $element: $element}; + if (settings.autoResize === true || settings.autoResize === 'true') { + $element + .dialog('option', {resizable: false, draggable: false}) + .dialog('widget').css('position', 'fixed'); + $(window) + .on('resize.dialogResize scroll.dialogResize', eventData, autoResize) + .trigger('resize.dialogResize'); + $(document).on('drupalViewportOffsetChange.dialogResize', eventData, autoResize); + } + }, + 'dialog:beforeclose': function (event, dialog, $element) { + $(window).off('.dialogResize'); + $(document).off('.dialogResize'); + } + }); + +})(jQuery, Drupal, drupalSettings, Drupal.debounce, Drupal.displace); diff --git a/core/misc/displace.es6.js b/core/misc/displace.es6.js new file mode 100644 index 0000000000..3e89c563ec --- /dev/null +++ b/core/misc/displace.es6.js @@ -0,0 +1,222 @@ +/** + * @file + * Manages elements that can offset the size of the viewport. + * + * Measures and reports viewport offset dimensions from elements like the + * toolbar that can potentially displace the positioning of other elements. + */ + +/** + * @typedef {object} Drupal~displaceOffset + * + * @prop {number} top + * @prop {number} left + * @prop {number} right + * @prop {number} bottom + */ + +/** + * Triggers when layout of the page changes. + * + * This is used to position fixed element on the page during page resize and + * Toolbar toggling. + * + * @event drupalViewportOffsetChange + */ + +(function ($, Drupal, debounce) { + + 'use strict'; + + /** + * @name Drupal.displace.offsets + * + * @type {Drupal~displaceOffset} + */ + var offsets = { + top: 0, + right: 0, + bottom: 0, + left: 0 + }; + + /** + * Registers a resize handler on the window. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.drupalDisplace = { + attach: function () { + // Mark this behavior as processed on the first pass. + if (this.displaceProcessed) { + return; + } + this.displaceProcessed = true; + + $(window).on('resize.drupalDisplace', debounce(displace, 200)); + } + }; + + /** + * Informs listeners of the current offset dimensions. + * + * @function Drupal.displace + * + * @prop {Drupal~displaceOffset} offsets + * + * @param {bool} [broadcast] + * When true or undefined, causes the recalculated offsets values to be + * broadcast to listeners. + * + * @return {Drupal~displaceOffset} + * An object whose keys are the for sides an element -- top, right, bottom + * and left. The value of each key is the viewport displacement distance for + * that edge. + * + * @fires event:drupalViewportOffsetChange + */ + function displace(broadcast) { + offsets = Drupal.displace.offsets = calculateOffsets(); + if (typeof broadcast === 'undefined' || broadcast) { + $(document).trigger('drupalViewportOffsetChange', offsets); + } + return offsets; + } + + /** + * Determines the viewport offsets. + * + * @return {Drupal~displaceOffset} + * An object whose keys are the for sides an element -- top, right, bottom + * and left. The value of each key is the viewport displacement distance for + * that edge. + */ + function calculateOffsets() { + return { + top: calculateOffset('top'), + right: calculateOffset('right'), + bottom: calculateOffset('bottom'), + left: calculateOffset('left') + }; + } + + /** + * Gets a specific edge's offset. + * + * Any element with the attribute data-offset-{edge} e.g. data-offset-top will + * be considered in the viewport offset calculations. If the attribute has a + * numeric value, that value will be used. If no value is provided, one will + * be calculated using the element's dimensions and placement. + * + * @function Drupal.displace.calculateOffset + * + * @param {string} edge + * The name of the edge to calculate. Can be 'top', 'right', + * 'bottom' or 'left'. + * + * @return {number} + * The viewport displacement distance for the requested edge. + */ + function calculateOffset(edge) { + var edgeOffset = 0; + var displacingElements = document.querySelectorAll('[data-offset-' + edge + ']'); + var n = displacingElements.length; + for (var i = 0; i < n; i++) { + var el = displacingElements[i]; + // If the element is not visible, do consider its dimensions. + if (el.style.display === 'none') { + continue; + } + // If the offset data attribute contains a displacing value, use it. + var displacement = parseInt(el.getAttribute('data-offset-' + edge), 10); + // If the element's offset data attribute exits + // but is not a valid number then get the displacement + // dimensions directly from the element. + if (isNaN(displacement)) { + displacement = getRawOffset(el, edge); + } + // If the displacement value is larger than the current value for this + // edge, use the displacement value. + edgeOffset = Math.max(edgeOffset, displacement); + } + + return edgeOffset; + } + + /** + * Calculates displacement for element based on its dimensions and placement. + * + * @param {HTMLElement} el + * The jQuery element whose dimensions and placement will be measured. + * + * @param {string} edge + * The name of the edge of the viewport that the element is associated + * with. + * + * @return {number} + * The viewport displacement distance for the requested edge. + */ + function getRawOffset(el, edge) { + var $el = $(el); + var documentElement = document.documentElement; + var displacement = 0; + var horizontal = (edge === 'left' || edge === 'right'); + // Get the offset of the element itself. + var placement = $el.offset()[horizontal ? 'left' : 'top']; + // Subtract scroll distance from placement to get the distance + // to the edge of the viewport. + placement -= window['scroll' + (horizontal ? 'X' : 'Y')] || document.documentElement['scroll' + (horizontal ? 'Left' : 'Top')] || 0; + // Find the displacement value according to the edge. + switch (edge) { + // Left and top elements displace as a sum of their own offset value + // plus their size. + case 'top': + // Total displacement is the sum of the elements placement and size. + displacement = placement + $el.outerHeight(); + break; + + case 'left': + // Total displacement is the sum of the elements placement and size. + displacement = placement + $el.outerWidth(); + break; + + // Right and bottom elements displace according to their left and + // top offset. Their size isn't important. + case 'bottom': + displacement = documentElement.clientHeight - placement; + break; + + case 'right': + displacement = documentElement.clientWidth - placement; + break; + + default: + displacement = 0; + } + return displacement; + } + + /** + * Assign the displace function to a property of the Drupal global object. + * + * @ignore + */ + Drupal.displace = displace; + $.extend(Drupal.displace, { + + /** + * Expose offsets to other scripts to avoid having to recalculate offsets. + * + * @ignore + */ + offsets: offsets, + + /** + * Expose method to compute a single edge offsets. + * + * @ignore + */ + calculateOffset: calculateOffset + }); + +})(jQuery, Drupal, Drupal.debounce); diff --git a/core/misc/dropbutton/dropbutton.es6.js b/core/misc/dropbutton/dropbutton.es6.js new file mode 100644 index 0000000000..04f9491ad7 --- /dev/null +++ b/core/misc/dropbutton/dropbutton.es6.js @@ -0,0 +1,233 @@ +/** + * @file + * Dropbutton feature. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Process elements with the .dropbutton class on page load. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches dropButton behaviors. + */ + Drupal.behaviors.dropButton = { + attach: function (context, settings) { + var $dropbuttons = $(context).find('.dropbutton-wrapper').once('dropbutton'); + if ($dropbuttons.length) { + // Adds the delegated handler that will toggle dropdowns on click. + var $body = $('body').once('dropbutton-click'); + if ($body.length) { + $body.on('click', '.dropbutton-toggle', dropbuttonClickHandler); + } + // Initialize all buttons. + var il = $dropbuttons.length; + for (var i = 0; i < il; i++) { + DropButton.dropbuttons.push(new DropButton($dropbuttons[i], settings.dropbutton)); + } + } + } + }; + + /** + * Delegated callback for opening and closing dropbutton secondary actions. + * + * @function Drupal.DropButton~dropbuttonClickHandler + * + * @param {jQuery.Event} e + * The event triggered. + */ + function dropbuttonClickHandler(e) { + e.preventDefault(); + $(e.target).closest('.dropbutton-wrapper').toggleClass('open'); + } + + /** + * A DropButton presents an HTML list as a button with a primary action. + * + * All secondary actions beyond the first in the list are presented in a + * dropdown list accessible through a toggle arrow associated with the button. + * + * @constructor Drupal.DropButton + * + * @param {HTMLElement} dropbutton + * A DOM element. + * @param {object} settings + * A list of options including: + * @param {string} settings.title + * The text inside the toggle link element. This text is hidden + * from visual UAs. + */ + function DropButton(dropbutton, settings) { + // Merge defaults with settings. + var options = $.extend({title: Drupal.t('List additional actions')}, settings); + var $dropbutton = $(dropbutton); + + /** + * @type {jQuery} + */ + this.$dropbutton = $dropbutton; + + /** + * @type {jQuery} + */ + this.$list = $dropbutton.find('.dropbutton'); + + /** + * Find actions and mark them. + * + * @type {jQuery} + */ + this.$actions = this.$list.find('li').addClass('dropbutton-action'); + + // Add the special dropdown only if there are hidden actions. + if (this.$actions.length > 1) { + // Identify the first element of the collection. + var $primary = this.$actions.slice(0, 1); + // Identify the secondary actions. + var $secondary = this.$actions.slice(1); + $secondary.addClass('secondary-action'); + // Add toggle link. + $primary.after(Drupal.theme('dropbuttonToggle', options)); + // Bind mouse events. + this.$dropbutton + .addClass('dropbutton-multiple') + .on({ + + /** + * Adds a timeout to close the dropdown on mouseleave. + * + * @ignore + */ + 'mouseleave.dropbutton': $.proxy(this.hoverOut, this), + + /** + * Clears timeout when mouseout of the dropdown. + * + * @ignore + */ + 'mouseenter.dropbutton': $.proxy(this.hoverIn, this), + + /** + * Similar to mouseleave/mouseenter, but for keyboard navigation. + * + * @ignore + */ + 'focusout.dropbutton': $.proxy(this.focusOut, this), + + /** + * @ignore + */ + 'focusin.dropbutton': $.proxy(this.focusIn, this) + }); + } + else { + this.$dropbutton.addClass('dropbutton-single'); + } + } + + /** + * Extend the DropButton constructor. + */ + $.extend(DropButton, /** @lends Drupal.DropButton */{ + /** + * Store all processed DropButtons. + * + * @type {Array.} + */ + dropbuttons: [] + }); + + /** + * Extend the DropButton prototype. + */ + $.extend(DropButton.prototype, /** @lends Drupal.DropButton# */{ + + /** + * Toggle the dropbutton open and closed. + * + * @param {bool} [show] + * Force the dropbutton to open by passing true or to close by + * passing false. + */ + toggle: function (show) { + var isBool = typeof show === 'boolean'; + show = isBool ? show : !this.$dropbutton.hasClass('open'); + this.$dropbutton.toggleClass('open', show); + }, + + /** + * @method + */ + hoverIn: function () { + // Clear any previous timer we were using. + if (this.timerID) { + window.clearTimeout(this.timerID); + } + }, + + /** + * @method + */ + hoverOut: function () { + // Wait half a second before closing. + this.timerID = window.setTimeout($.proxy(this, 'close'), 500); + }, + + /** + * @method + */ + open: function () { + this.toggle(true); + }, + + /** + * @method + */ + close: function () { + this.toggle(false); + }, + + /** + * @param {jQuery.Event} e + * The event triggered. + */ + focusOut: function (e) { + this.hoverOut.call(this, e); + }, + + /** + * @param {jQuery.Event} e + * The event triggered. + */ + focusIn: function (e) { + this.hoverIn.call(this, e); + } + }); + + $.extend(Drupal.theme, /** @lends Drupal.theme */{ + + /** + * A toggle is an interactive element often bound to a click handler. + * + * @param {object} options + * Options object. + * @param {string} [options.title] + * The HTML anchor title attribute and text for the inner span element. + * + * @return {string} + * A string representing a DOM fragment. + */ + dropbuttonToggle: function (options) { + return '
  • '; + } + }); + + // Expose constructor in the public space. + Drupal.DropButton = DropButton; + +})(jQuery, Drupal); diff --git a/core/misc/drupal.es6.js b/core/misc/drupal.es6.js new file mode 100644 index 0000000000..d509795b86 --- /dev/null +++ b/core/misc/drupal.es6.js @@ -0,0 +1,583 @@ +/** + * @file + * Defines the Drupal JavaScript API. + */ + +/** + * A jQuery object, typically the return value from a `$(selector)` call. + * + * Holds an HTMLElement or a collection of HTMLElements. + * + * @typedef {object} jQuery + * + * @prop {number} length=0 + * Number of elements contained in the jQuery object. + */ + +/** + * Variable generated by Drupal that holds all translated strings from PHP. + * + * Content of this variable is automatically created by Drupal when using the + * Interface Translation module. It holds the translation of strings used on + * the page. + * + * This variable is used to pass data from the backend to the frontend. Data + * contained in `drupalSettings` is used during behavior initialization. + * + * @global + * + * @var {object} drupalTranslations + */ + +/** + * Global Drupal object. + * + * All Drupal JavaScript APIs are contained in this namespace. + * + * @global + * + * @namespace + */ +window.Drupal = {behaviors: {}, locale: {}}; + +// JavaScript should be made compatible with libraries other than jQuery by +// wrapping it in an anonymous closure. +(function (Drupal, drupalSettings, drupalTranslations) { + + 'use strict'; + + /** + * Helper to rethrow errors asynchronously. + * + * This way Errors bubbles up outside of the original callstack, making it + * easier to debug errors in the browser. + * + * @param {Error|string} error + * The error to be thrown. + */ + Drupal.throwError = function (error) { + setTimeout(function () { throw error; }, 0); + }; + + /** + * Custom error thrown after attach/detach if one or more behaviors failed. + * Initializes the JavaScript behaviors for page loads and Ajax requests. + * + * @callback Drupal~behaviorAttach + * + * @param {HTMLDocument|HTMLElement} context + * An element to detach behaviors from. + * @param {?object} settings + * An object containing settings for the current context. It is rarely used. + * + * @see Drupal.attachBehaviors + */ + + /** + * Reverts and cleans up JavaScript behavior initialization. + * + * @callback Drupal~behaviorDetach + * + * @param {HTMLDocument|HTMLElement} context + * An element to attach behaviors to. + * @param {object} settings + * An object containing settings for the current context. + * @param {string} trigger + * One of `'unload'`, `'move'`, or `'serialize'`. + * + * @see Drupal.detachBehaviors + */ + + /** + * @typedef {object} Drupal~behavior + * + * @prop {Drupal~behaviorAttach} attach + * Function run on page load and after an Ajax call. + * @prop {Drupal~behaviorDetach} detach + * Function run when content is serialized or removed from the page. + */ + + /** + * Holds all initialization methods. + * + * @namespace Drupal.behaviors + * + * @type {Object.} + */ + + /** + * Defines a behavior to be run during attach and detach phases. + * + * Attaches all registered behaviors to a page element. + * + * Behaviors are event-triggered actions that attach to page elements, + * enhancing default non-JavaScript UIs. Behaviors are registered in the + * {@link Drupal.behaviors} object using the method 'attach' and optionally + * also 'detach'. + * + * {@link Drupal.attachBehaviors} is added below to the `jQuery.ready` event + * and therefore runs on initial page load. Developers implementing Ajax in + * their solutions should also call this function after new page content has + * been loaded, feeding in an element to be processed, in order to attach all + * behaviors to the new content. + * + * Behaviors should use `var elements = + * $(context).find(selector).once('behavior-name');` to ensure the behavior is + * attached only once to a given element. (Doing so enables the reprocessing + * of given elements, which may be needed on occasion despite the ability to + * limit behavior attachment to a particular element.) + * + * @example + * Drupal.behaviors.behaviorName = { + * attach: function (context, settings) { + * // ... + * }, + * detach: function (context, settings, trigger) { + * // ... + * } + * }; + * + * @param {HTMLDocument|HTMLElement} [context=document] + * An element to attach behaviors to. + * @param {object} [settings=drupalSettings] + * An object containing settings for the current context. If none is given, + * the global {@link drupalSettings} object is used. + * + * @see Drupal~behaviorAttach + * @see Drupal.detachBehaviors + * + * @throws {Drupal~DrupalBehaviorError} + */ + Drupal.attachBehaviors = function (context, settings) { + context = context || document; + settings = settings || drupalSettings; + var behaviors = Drupal.behaviors; + // Execute all of them. + for (var i in behaviors) { + if (behaviors.hasOwnProperty(i) && typeof behaviors[i].attach === 'function') { + // Don't stop the execution of behaviors in case of an error. + try { + behaviors[i].attach(context, settings); + } + catch (e) { + Drupal.throwError(e); + } + } + } + }; + + /** + * Detaches registered behaviors from a page element. + * + * Developers implementing Ajax in their solutions should call this function + * before page content is about to be removed, feeding in an element to be + * processed, in order to allow special behaviors to detach from the content. + * + * Such implementations should use `.findOnce()` and `.removeOnce()` to find + * elements with their corresponding `Drupal.behaviors.behaviorName.attach` + * implementation, i.e. `.removeOnce('behaviorName')`, to ensure the behavior + * is detached only from previously processed elements. + * + * @param {HTMLDocument|HTMLElement} [context=document] + * An element to detach behaviors from. + * @param {object} [settings=drupalSettings] + * An object containing settings for the current context. If none given, + * the global {@link drupalSettings} object is used. + * @param {string} [trigger='unload'] + * A string containing what's causing the behaviors to be detached. The + * possible triggers are: + * - `'unload'`: The context element is being removed from the DOM. + * - `'move'`: The element is about to be moved within the DOM (for example, + * during a tabledrag row swap). After the move is completed, + * {@link Drupal.attachBehaviors} is called, so that the behavior can undo + * whatever it did in response to the move. Many behaviors won't need to + * do anything simply in response to the element being moved, but because + * IFRAME elements reload their "src" when being moved within the DOM, + * behaviors bound to IFRAME elements (like WYSIWYG editors) may need to + * take some action. + * - `'serialize'`: When an Ajax form is submitted, this is called with the + * form as the context. This provides every behavior within the form an + * opportunity to ensure that the field elements have correct content + * in them before the form is serialized. The canonical use-case is so + * that WYSIWYG editors can update the hidden textarea to which they are + * bound. + * + * @throws {Drupal~DrupalBehaviorError} + * + * @see Drupal~behaviorDetach + * @see Drupal.attachBehaviors + */ + Drupal.detachBehaviors = function (context, settings, trigger) { + context = context || document; + settings = settings || drupalSettings; + trigger = trigger || 'unload'; + var behaviors = Drupal.behaviors; + // Execute all of them. + for (var i in behaviors) { + if (behaviors.hasOwnProperty(i) && typeof behaviors[i].detach === 'function') { + // Don't stop the execution of behaviors in case of an error. + try { + behaviors[i].detach(context, settings, trigger); + } + catch (e) { + Drupal.throwError(e); + } + } + } + }; + + /** + * Encodes special characters in a plain-text string for display as HTML. + * + * @param {string} str + * The string to be encoded. + * + * @return {string} + * The encoded string. + * + * @ingroup sanitization + */ + Drupal.checkPlain = function (str) { + str = str.toString() + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>'); + return str; + }; + + /** + * Replaces placeholders with sanitized values in a string. + * + * @param {string} str + * A string with placeholders. + * @param {object} args + * An object of replacements pairs to make. Incidences of any key in this + * array are replaced with the corresponding value. Based on the first + * character of the key, the value is escaped and/or themed: + * - `'!variable'`: inserted as is. + * - `'@variable'`: escape plain text to HTML ({@link Drupal.checkPlain}). + * - `'%variable'`: escape text and theme as a placeholder for user- + * submitted content ({@link Drupal.checkPlain} + + * `{@link Drupal.theme}('placeholder')`). + * + * @return {string} + * The formatted string. + * + * @see Drupal.t + */ + Drupal.formatString = function (str, args) { + // Keep args intact. + var processedArgs = {}; + // Transform arguments before inserting them. + for (var key in args) { + if (args.hasOwnProperty(key)) { + switch (key.charAt(0)) { + // Escaped only. + case '@': + processedArgs[key] = Drupal.checkPlain(args[key]); + break; + + // Pass-through. + case '!': + processedArgs[key] = args[key]; + break; + + // Escaped and placeholder. + default: + processedArgs[key] = Drupal.theme('placeholder', args[key]); + break; + } + } + } + + return Drupal.stringReplace(str, processedArgs, null); + }; + + /** + * Replaces substring. + * + * The longest keys will be tried first. Once a substring has been replaced, + * its new value will not be searched again. + * + * @param {string} str + * A string with placeholders. + * @param {object} args + * Key-value pairs. + * @param {Array|null} keys + * Array of keys from `args`. Internal use only. + * + * @return {string} + * The replaced string. + */ + Drupal.stringReplace = function (str, args, keys) { + if (str.length === 0) { + return str; + } + + // If the array of keys is not passed then collect the keys from the args. + if (!Array.isArray(keys)) { + keys = []; + for (var k in args) { + if (args.hasOwnProperty(k)) { + keys.push(k); + } + } + + // Order the keys by the character length. The shortest one is the first. + keys.sort(function (a, b) { return a.length - b.length; }); + } + + if (keys.length === 0) { + return str; + } + + // Take next longest one from the end. + var key = keys.pop(); + var fragments = str.split(key); + + if (keys.length) { + for (var i = 0; i < fragments.length; i++) { + // Process each fragment with a copy of remaining keys. + fragments[i] = Drupal.stringReplace(fragments[i], args, keys.slice(0)); + } + } + + return fragments.join(args[key]); + }; + + /** + * Translates strings to the page language, or a given language. + * + * See the documentation of the server-side t() function for further details. + * + * @param {string} str + * A string containing the English text to translate. + * @param {Object.} [args] + * An object of replacements pairs to make after translation. Incidences + * of any key in this array are replaced with the corresponding value. + * See {@link Drupal.formatString}. + * @param {object} [options] + * Additional options for translation. + * @param {string} [options.context=''] + * The context the source string belongs to. + * + * @return {string} + * The formatted string. + * The translated string. + */ + Drupal.t = function (str, args, options) { + options = options || {}; + options.context = options.context || ''; + + // Fetch the localized version of the string. + if (typeof drupalTranslations !== 'undefined' && drupalTranslations.strings && drupalTranslations.strings[options.context] && drupalTranslations.strings[options.context][str]) { + str = drupalTranslations.strings[options.context][str]; + } + + if (args) { + str = Drupal.formatString(str, args); + } + return str; + }; + + /** + * Returns the URL to a Drupal page. + * + * @param {string} path + * Drupal path to transform to URL. + * + * @return {string} + * The full URL. + */ + Drupal.url = function (path) { + return drupalSettings.path.baseUrl + drupalSettings.path.pathPrefix + path; + }; + + /** + * Returns the passed in URL as an absolute URL. + * + * @param {string} url + * The URL string to be normalized to an absolute URL. + * + * @return {string} + * The normalized, absolute URL. + * + * @see https://github.com/angular/angular.js/blob/v1.4.4/src/ng/urlUtils.js + * @see https://grack.com/blog/2009/11/17/absolutizing-url-in-javascript + * @see https://github.com/jquery/jquery-ui/blob/1.11.4/ui/tabs.js#L53 + */ + Drupal.url.toAbsolute = function (url) { + var urlParsingNode = document.createElement('a'); + + // Decode the URL first; this is required by IE <= 6. Decoding non-UTF-8 + // strings may throw an exception. + try { + url = decodeURIComponent(url); + } + catch (e) { + // Empty. + } + + urlParsingNode.setAttribute('href', url); + + // IE <= 7 normalizes the URL when assigned to the anchor node similar to + // the other browsers. + return urlParsingNode.cloneNode(false).href; + }; + + /** + * Returns true if the URL is within Drupal's base path. + * + * @param {string} url + * The URL string to be tested. + * + * @return {bool} + * `true` if local. + * + * @see https://github.com/jquery/jquery-ui/blob/1.11.4/ui/tabs.js#L58 + */ + Drupal.url.isLocal = function (url) { + // Always use browser-derived absolute URLs in the comparison, to avoid + // attempts to break out of the base path using directory traversal. + var absoluteUrl = Drupal.url.toAbsolute(url); + var protocol = location.protocol; + + // Consider URLs that match this site's base URL but use HTTPS instead of HTTP + // as local as well. + if (protocol === 'http:' && absoluteUrl.indexOf('https:') === 0) { + protocol = 'https:'; + } + var baseUrl = protocol + '//' + location.host + drupalSettings.path.baseUrl.slice(0, -1); + + // Decoding non-UTF-8 strings may throw an exception. + try { + absoluteUrl = decodeURIComponent(absoluteUrl); + } + catch (e) { + // Empty. + } + try { + baseUrl = decodeURIComponent(baseUrl); + } + catch (e) { + // Empty. + } + + // The given URL matches the site's base URL, or has a path under the site's + // base URL. + return absoluteUrl === baseUrl || absoluteUrl.indexOf(baseUrl + '/') === 0; + }; + + /** + * Formats a string containing a count of items. + * + * This function ensures that the string is pluralized correctly. Since + * {@link Drupal.t} is called by this function, make sure not to pass + * already-localized strings to it. + * + * See the documentation of the server-side + * \Drupal\Core\StringTranslation\TranslationInterface::formatPlural() + * function for more details. + * + * @param {number} count + * The item count to display. + * @param {string} singular + * The string for the singular case. Please make sure it is clear this is + * singular, to ease translation (e.g. use "1 new comment" instead of "1 + * new"). Do not use @count in the singular string. + * @param {string} plural + * The string for the plural case. Please make sure it is clear this is + * plural, to ease translation. Use @count in place of the item count, as in + * "@count new comments". + * @param {object} [args] + * An object of replacements pairs to make after translation. Incidences + * of any key in this array are replaced with the corresponding value. + * See {@link Drupal.formatString}. + * Note that you do not need to include @count in this array. + * This replacement is done automatically for the plural case. + * @param {object} [options] + * The options to pass to the {@link Drupal.t} function. + * + * @return {string} + * A translated string. + */ + Drupal.formatPlural = function (count, singular, plural, args, options) { + args = args || {}; + args['@count'] = count; + + var pluralDelimiter = drupalSettings.pluralDelimiter; + var translations = Drupal.t(singular + pluralDelimiter + plural, args, options).split(pluralDelimiter); + var index = 0; + + // Determine the index of the plural form. + if (typeof drupalTranslations !== 'undefined' && drupalTranslations.pluralFormula) { + index = count in drupalTranslations.pluralFormula ? drupalTranslations.pluralFormula[count] : drupalTranslations.pluralFormula['default']; + } + else if (args['@count'] !== 1) { + index = 1; + } + + return translations[index]; + }; + + /** + * Encodes a Drupal path for use in a URL. + * + * For aesthetic reasons slashes are not escaped. + * + * @param {string} item + * Unencoded path. + * + * @return {string} + * The encoded path. + */ + Drupal.encodePath = function (item) { + return window.encodeURIComponent(item).replace(/%2F/g, '/'); + }; + + /** + * Generates the themed representation of a Drupal object. + * + * All requests for themed output must go through this function. It examines + * the request and routes it to the appropriate theme function. If the current + * theme does not provide an override function, the generic theme function is + * called. + * + * @example + *
    + * Drupal.theme('placeholder', text); + * + * @namespace + * + * @param {function} func + * The name of the theme function to call. + * @param {...args} + * Additional arguments to pass along to the theme function. + * + * @return {string|object|HTMLElement|jQuery} + * Any data the theme function returns. This could be a plain HTML string, + * but also a complex object. + */ + Drupal.theme = function (func) { + var args = Array.prototype.slice.apply(arguments, [1]); + if (func in Drupal.theme) { + return Drupal.theme[func].apply(this, args); + } + }; + + /** + * Formats text for emphasized display in a placeholder inside a sentence. + * + * @param {string} str + * The text to format (plain-text). + * + * @return {string} + * The formatted text (html). + */ + Drupal.theme.placeholder = function (str) { + return '' + Drupal.checkPlain(str) + ''; + }; + +})(Drupal, window.drupalSettings, window.drupalTranslations); diff --git a/core/misc/drupal.init.es6.js b/core/misc/drupal.init.es6.js new file mode 100644 index 0000000000..0e55e190ef --- /dev/null +++ b/core/misc/drupal.init.es6.js @@ -0,0 +1,19 @@ +// Allow other JavaScript libraries to use $. +if (window.jQuery) { + jQuery.noConflict(); +} + +// Class indicating that JS is enabled; used for styling purpose. +document.documentElement.className += ' js'; + +// JavaScript should be made compatible with libraries other than jQuery by +// wrapping it in an anonymous closure. + +(function (domready, Drupal, drupalSettings) { + + 'use strict'; + + // Attach all behaviors. + domready(function () { Drupal.attachBehaviors(document, drupalSettings); }); + +})(domready, Drupal, window.drupalSettings); diff --git a/core/misc/drupalSettingsLoader.es6.js b/core/misc/drupalSettingsLoader.es6.js new file mode 100644 index 0000000000..7ff292efbf --- /dev/null +++ b/core/misc/drupalSettingsLoader.es6.js @@ -0,0 +1,25 @@ +/** + * @file + * Parse inline JSON and initialize the drupalSettings global object. + */ + +(function () { + + 'use strict'; + + // Use direct child elements to harden against XSS exploits when CSP is on. + var settingsElement = document.querySelector('head > script[type="application/json"][data-drupal-selector="drupal-settings-json"], body > script[type="application/json"][data-drupal-selector="drupal-settings-json"]'); + + /** + * Variable generated by Drupal with all the configuration created from PHP. + * + * @global + * + * @type {object} + */ + window.drupalSettings = {}; + + if (settingsElement !== null) { + window.drupalSettings = JSON.parse(settingsElement.textContent); + } +})(); diff --git a/core/misc/entity-form.es6.js b/core/misc/entity-form.es6.js new file mode 100644 index 0000000000..87253c9022 --- /dev/null +++ b/core/misc/entity-form.es6.js @@ -0,0 +1,57 @@ +/** + * @file + * Defines Javascript behaviors for the block_content module. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Sets summaries about revision and translation of entities. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches summary behaviour entity form tabs. + * + * Specifically, it updates summaries to the revision information and the + * translation options. + */ + Drupal.behaviors.entityContentDetailsSummaries = { + attach: function (context) { + var $context = $(context); + $context.find('.entity-content-form-revision-information').drupalSetSummary(function (context) { + var $revisionContext = $(context); + var revisionCheckbox = $revisionContext.find('.js-form-item-revision input'); + + // Return 'New revision' if the 'Create new revision' checkbox is checked, + // or if the checkbox doesn't exist, but the revision log does. For users + // without the "Administer content" permission the checkbox won't appear, + // but the revision log will if the content type is set to auto-revision. + if (revisionCheckbox.is(':checked') || (!revisionCheckbox.length && $revisionContext.find('.js-form-item-revision-log textarea').length)) { + return Drupal.t('New revision'); + } + + return Drupal.t('No revision'); + }); + + $context.find('details.entity-translation-options').drupalSetSummary(function (context) { + var $translationContext = $(context); + var translate; + var $checkbox = $translationContext.find('.js-form-item-translation-translate input'); + + if ($checkbox.length) { + translate = $checkbox.is(':checked') ? Drupal.t('Needs to be updated') : Drupal.t('Does not need to be updated'); + } + else { + $checkbox = $translationContext.find('.js-form-item-translation-retranslate input'); + translate = $checkbox.is(':checked') ? Drupal.t('Flag other translations as outdated') : Drupal.t('Do not flag other translations as outdated'); + } + + return translate; + }); + } + }; + +})(jQuery, Drupal); diff --git a/core/misc/form.es6.js b/core/misc/form.es6.js new file mode 100644 index 0000000000..7ca64fc425 --- /dev/null +++ b/core/misc/form.es6.js @@ -0,0 +1,250 @@ +/** + * @file + * Form features. + */ + +/** + * Triggers when a value in the form changed. + * + * The event triggers when content is typed or pasted in a text field, before + * the change event triggers. + * + * @event formUpdated + */ + +(function ($, Drupal, debounce) { + + 'use strict'; + + /** + * Retrieves the summary for the first element. + * + * @return {string} + * The text of the summary. + */ + $.fn.drupalGetSummary = function () { + var callback = this.data('summaryCallback'); + return (this[0] && callback) ? $.trim(callback(this[0])) : ''; + }; + + /** + * Sets the summary for all matched elements. + * + * @param {function} callback + * Either a function that will be called each time the summary is + * retrieved or a string (which is returned each time). + * + * @return {jQuery} + * jQuery collection of the current element. + * + * @fires event:summaryUpdated + * + * @listens event:formUpdated + */ + $.fn.drupalSetSummary = function (callback) { + var self = this; + + // To facilitate things, the callback should always be a function. If it's + // not, we wrap it into an anonymous function which just returns the value. + if (typeof callback !== 'function') { + var val = callback; + callback = function () { return val; }; + } + + return this + .data('summaryCallback', callback) + // To prevent duplicate events, the handlers are first removed and then + // (re-)added. + .off('formUpdated.summary') + .on('formUpdated.summary', function () { + self.trigger('summaryUpdated'); + }) + // The actual summaryUpdated handler doesn't fire when the callback is + // changed, so we have to do this manually. + .trigger('summaryUpdated'); + }; + + /** + * Prevents consecutive form submissions of identical form values. + * + * Repetitive form submissions that would submit the identical form values + * are prevented, unless the form values are different to the previously + * submitted values. + * + * This is a simplified re-implementation of a user-agent behavior that + * should be natively supported by major web browsers, but at this time, only + * Firefox has a built-in protection. + * + * A form value-based approach ensures that the constraint is triggered for + * consecutive, identical form submissions only. Compared to that, a form + * button-based approach would (1) rely on [visible] buttons to exist where + * technically not required and (2) require more complex state management if + * there are multiple buttons in a form. + * + * This implementation is based on form-level submit events only and relies + * on jQuery's serialize() method to determine submitted form values. As such, + * the following limitations exist: + * + * - Event handlers on form buttons that preventDefault() do not receive a + * double-submit protection. That is deemed to be fine, since such button + * events typically trigger reversible client-side or server-side + * operations that are local to the context of a form only. + * - Changed values in advanced form controls, such as file inputs, are not + * part of the form values being compared between consecutive form submits + * (due to limitations of jQuery.serialize()). That is deemed to be + * acceptable, because if the user forgot to attach a file, then the size of + * HTTP payload will most likely be small enough to be fully passed to the + * server endpoint within (milli)seconds. If a user mistakenly attached a + * wrong file and is technically versed enough to cancel the form submission + * (and HTTP payload) in order to attach a different file, then that + * edge-case is not supported here. + * + * Lastly, all forms submitted via HTTP GET are idempotent by definition of + * HTTP standards, so excluded in this implementation. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.formSingleSubmit = { + attach: function () { + function onFormSubmit(e) { + var $form = $(e.currentTarget); + var formValues = $form.serialize(); + var previousValues = $form.attr('data-drupal-form-submit-last'); + if (previousValues === formValues) { + e.preventDefault(); + } + else { + $form.attr('data-drupal-form-submit-last', formValues); + } + } + + $('body').once('form-single-submit') + .on('submit.singleSubmit', 'form:not([method~="GET"])', onFormSubmit); + } + }; + + /** + * Sends a 'formUpdated' event each time a form element is modified. + * + * @param {HTMLElement} element + * The element to trigger a form updated event on. + * + * @fires event:formUpdated + */ + function triggerFormUpdated(element) { + $(element).trigger('formUpdated'); + } + + /** + * Collects the IDs of all form fields in the given form. + * + * @param {HTMLFormElement} form + * The form element to search. + * + * @return {Array} + * Array of IDs for form fields. + */ + function fieldsList(form) { + var $fieldList = $(form).find('[name]').map(function (index, element) { + // We use id to avoid name duplicates on radio fields and filter out + // elements with a name but no id. + return element.getAttribute('id'); + }); + // Return a true array. + return $.makeArray($fieldList); + } + + /** + * Triggers the 'formUpdated' event on form elements when they are modified. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches formUpdated behaviors. + * @prop {Drupal~behaviorDetach} detach + * Detaches formUpdated behaviors. + * + * @fires event:formUpdated + */ + Drupal.behaviors.formUpdated = { + attach: function (context) { + var $context = $(context); + var contextIsForm = $context.is('form'); + var $forms = (contextIsForm ? $context : $context.find('form')).once('form-updated'); + var formFields; + + if ($forms.length) { + // Initialize form behaviors, use $.makeArray to be able to use native + // forEach array method and have the callback parameters in the right + // order. + $.makeArray($forms).forEach(function (form) { + var events = 'change.formUpdated input.formUpdated '; + var eventHandler = debounce(function (event) { triggerFormUpdated(event.target); }, 300); + formFields = fieldsList(form).join(','); + + form.setAttribute('data-drupal-form-fields', formFields); + $(form).on(events, eventHandler); + }); + } + // On ajax requests context is the form element. + if (contextIsForm) { + formFields = fieldsList(context).join(','); + // @todo replace with form.getAttribute() when #1979468 is in. + var currentFields = $(context).attr('data-drupal-form-fields'); + // If there has been a change in the fields or their order, trigger + // formUpdated. + if (formFields !== currentFields) { + triggerFormUpdated(context); + } + } + + }, + detach: function (context, settings, trigger) { + var $context = $(context); + var contextIsForm = $context.is('form'); + if (trigger === 'unload') { + var $forms = (contextIsForm ? $context : $context.find('form')).removeOnce('form-updated'); + if ($forms.length) { + $.makeArray($forms).forEach(function (form) { + form.removeAttribute('data-drupal-form-fields'); + $(form).off('.formUpdated'); + }); + } + } + } + }; + + /** + * Prepopulate form fields with information from the visitor browser. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the behavior for filling user info from browser. + */ + Drupal.behaviors.fillUserInfoFromBrowser = { + attach: function (context, settings) { + var userInfo = ['name', 'mail', 'homepage']; + var $forms = $('[data-user-info-from-browser]').once('user-info-from-browser'); + if ($forms.length) { + userInfo.map(function (info) { + var $element = $forms.find('[name=' + info + ']'); + var browserData = localStorage.getItem('Drupal.visitor.' + info); + var emptyOrDefault = ($element.val() === '' || ($element.attr('data-drupal-default-value') === $element.val())); + if ($element.length && emptyOrDefault && browserData) { + $element.val(browserData); + } + }); + } + $forms.on('submit', function () { + userInfo.map(function (info) { + var $element = $forms.find('[name=' + info + ']'); + if ($element.length) { + localStorage.setItem('Drupal.visitor.' + info, $element.val()); + } + }); + }); + } + }; + +})(jQuery, Drupal, Drupal.debounce); diff --git a/core/misc/machine-name.es6.js b/core/misc/machine-name.es6.js new file mode 100644 index 0000000000..e76292e265 --- /dev/null +++ b/core/misc/machine-name.es6.js @@ -0,0 +1,211 @@ +/** + * @file + * Machine name functionality. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Attach the machine-readable name form element behavior. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches machine-name behaviors. + */ + Drupal.behaviors.machineName = { + + /** + * Attaches the behavior. + * + * @param {Element} context + * The context for attaching the behavior. + * @param {object} settings + * Settings object. + * @param {object} settings.machineName + * A list of elements to process, keyed by the HTML ID of the form + * element containing the human-readable value. Each element is an object + * defining the following properties: + * - target: The HTML ID of the machine name form element. + * - suffix: The HTML ID of a container to show the machine name preview + * in (usually a field suffix after the human-readable name + * form element). + * - label: The label to show for the machine name preview. + * - replace_pattern: A regular expression (without modifiers) matching + * disallowed characters in the machine name; e.g., '[^a-z0-9]+'. + * - replace: A character to replace disallowed characters with; e.g., + * '_' or '-'. + * - standalone: Whether the preview should stay in its own element + * rather than the suffix of the source element. + * - field_prefix: The #field_prefix of the form element. + * - field_suffix: The #field_suffix of the form element. + */ + attach: function (context, settings) { + var self = this; + var $context = $(context); + var timeout = null; + var xhr = null; + + function clickEditHandler(e) { + var data = e.data; + data.$wrapper.removeClass('visually-hidden'); + data.$target.trigger('focus'); + data.$suffix.hide(); + data.$source.off('.machineName'); + } + + function machineNameHandler(e) { + var data = e.data; + var options = data.options; + var baseValue = $(e.target).val(); + + var rx = new RegExp(options.replace_pattern, 'g'); + var expected = baseValue.toLowerCase().replace(rx, options.replace).substr(0, options.maxlength); + + // Abort the last pending request because the label has changed and it + // is no longer valid. + if (xhr && xhr.readystate !== 4) { + xhr.abort(); + xhr = null; + } + + // Wait 300 milliseconds for Ajax request since the last event to update + // the machine name i.e., after the user has stopped typing. + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + if (baseValue.toLowerCase() !== expected) { + timeout = setTimeout(function () { + xhr = self.transliterate(baseValue, options).done(function (machine) { + self.showMachineName(machine.substr(0, options.maxlength), data); + }); + }, 300); + } + else { + self.showMachineName(expected, data); + } + } + + Object.keys(settings.machineName).forEach(function (source_id) { + var machine = ''; + var eventData; + var options = settings.machineName[source_id]; + + var $source = $context.find(source_id).addClass('machine-name-source').once('machine-name'); + var $target = $context.find(options.target).addClass('machine-name-target'); + var $suffix = $context.find(options.suffix); + var $wrapper = $target.closest('.js-form-item'); + // All elements have to exist. + if (!$source.length || !$target.length || !$suffix.length || !$wrapper.length) { + return; + } + // Skip processing upon a form validation error on the machine name. + if ($target.hasClass('error')) { + return; + } + // Figure out the maximum length for the machine name. + options.maxlength = $target.attr('maxlength'); + // Hide the form item container of the machine name form element. + $wrapper.addClass('visually-hidden'); + // Determine the initial machine name value. Unless the machine name + // form element is disabled or not empty, the initial default value is + // based on the human-readable form element value. + if ($target.is(':disabled') || $target.val() !== '') { + machine = $target.val(); + } + else if ($source.val() !== '') { + machine = self.transliterate($source.val(), options); + } + // Append the machine name preview to the source field. + var $preview = $('' + options.field_prefix + Drupal.checkPlain(machine) + options.field_suffix + ''); + $suffix.empty(); + if (options.label) { + $suffix.append('' + options.label + ': '); + } + $suffix.append($preview); + + // If the machine name cannot be edited, stop further processing. + if ($target.is(':disabled')) { + return; + } + + eventData = { + $source: $source, + $target: $target, + $suffix: $suffix, + $wrapper: $wrapper, + $preview: $preview, + options: options + }; + // If it is editable, append an edit link. + var $link = $('').on('click', eventData, clickEditHandler); + $suffix.append($link); + + // Preview the machine name in realtime when the human-readable name + // changes, but only if there is no machine name yet; i.e., only upon + // initial creation, not when editing. + if ($target.val() === '') { + $source.on('formUpdated.machineName', eventData, machineNameHandler) + // Initialize machine name preview. + .trigger('formUpdated.machineName'); + } + + // Add a listener for an invalid event on the machine name input + // to show its container and focus it. + $target.on('invalid', eventData, clickEditHandler); + }); + }, + + showMachineName: function (machine, data) { + var settings = data.options; + // Set the machine name to the transliterated value. + if (machine !== '') { + if (machine !== settings.replace) { + data.$target.val(machine); + data.$preview.html(settings.field_prefix + Drupal.checkPlain(machine) + settings.field_suffix); + } + data.$suffix.show(); + } + else { + data.$suffix.hide(); + data.$target.val(machine); + data.$preview.empty(); + } + }, + + /** + * Transliterate a human-readable name to a machine name. + * + * @param {string} source + * A string to transliterate. + * @param {object} settings + * The machine name settings for the corresponding field. + * @param {string} settings.replace_pattern + * A regular expression (without modifiers) matching disallowed characters + * in the machine name; e.g., '[^a-z0-9]+'. + * @param {string} settings.replace_token + * A token to validate the regular expression. + * @param {string} settings.replace + * A character to replace disallowed characters with; e.g., '_' or '-'. + * @param {number} settings.maxlength + * The maximum length of the machine name. + * + * @return {jQuery} + * The transliterated source string. + */ + transliterate: function (source, settings) { + return $.get(Drupal.url('machine_name/transliterate'), { + text: source, + langcode: drupalSettings.langcode, + replace_pattern: settings.replace_pattern, + replace_token: settings.replace_token, + replace: settings.replace, + lowercase: true + }); + } + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/misc/progress.es6.js b/core/misc/progress.es6.js new file mode 100644 index 0000000000..a6694892b2 --- /dev/null +++ b/core/misc/progress.es6.js @@ -0,0 +1,169 @@ +/** + * @file + * Progress bar. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Theme function for the progress bar. + * + * @param {string} id + * The id for the progress bar. + * + * @return {string} + * The HTML for the progress bar. + */ + Drupal.theme.progressBar = function (id) { + return '
    ' + + '
     
    ' + + '
    ' + + '
    ' + + '
     
    ' + + '
    '; + }; + + /** + * A progressbar object. Initialized with the given id. Must be inserted into + * the DOM afterwards through progressBar.element. + * + * Method is the function which will perform the HTTP request to get the + * progress bar state. Either "GET" or "POST". + * + * @example + * pb = new Drupal.ProgressBar('myProgressBar'); + * some_element.appendChild(pb.element); + * + * @constructor + * + * @param {string} id + * The id for the progressbar. + * @param {function} updateCallback + * Callback to run on update. + * @param {string} method + * HTTP method to use. + * @param {function} errorCallback + * Callback to call on error. + */ + Drupal.ProgressBar = function (id, updateCallback, method, errorCallback) { + this.id = id; + this.method = method || 'GET'; + this.updateCallback = updateCallback; + this.errorCallback = errorCallback; + + // The WAI-ARIA setting aria-live="polite" will announce changes after + // users + // have completed their current activity and not interrupt the screen + // reader. + this.element = $(Drupal.theme('progressBar', id)); + }; + + $.extend(Drupal.ProgressBar.prototype, /** @lends Drupal.ProgressBar# */{ + + /** + * Set the percentage and status message for the progressbar. + * + * @param {number} percentage + * The progress percentage. + * @param {string} message + * The message to show the user. + * @param {string} label + * The text for the progressbar label. + */ + setProgress: function (percentage, message, label) { + if (percentage >= 0 && percentage <= 100) { + $(this.element).find('div.progress__bar').css('width', percentage + '%'); + $(this.element).find('div.progress__percentage').html(percentage + '%'); + } + $('div.progress__description', this.element).html(message); + $('div.progress__label', this.element).html(label); + if (this.updateCallback) { + this.updateCallback(percentage, message, this); + } + }, + + /** + * Start monitoring progress via Ajax. + * + * @param {string} uri + * The URI to use for monitoring. + * @param {number} delay + * The delay for calling the monitoring URI. + */ + startMonitoring: function (uri, delay) { + this.delay = delay; + this.uri = uri; + this.sendPing(); + }, + + /** + * Stop monitoring progress via Ajax. + */ + stopMonitoring: function () { + clearTimeout(this.timer); + // This allows monitoring to be stopped from within the callback. + this.uri = null; + }, + + /** + * Request progress data from server. + */ + sendPing: function () { + if (this.timer) { + clearTimeout(this.timer); + } + if (this.uri) { + var pb = this; + // When doing a post request, you need non-null data. Otherwise a + // HTTP 411 or HTTP 406 (with Apache mod_security) error may result. + var uri = this.uri; + if (uri.indexOf('?') === -1) { + uri += '?'; + } + else { + uri += '&'; + } + uri += '_format=json'; + $.ajax({ + type: this.method, + url: uri, + data: '', + dataType: 'json', + success: function (progress) { + // Display errors. + if (progress.status === 0) { + pb.displayError(progress.data); + return; + } + // Update display. + pb.setProgress(progress.percentage, progress.message, progress.label); + // Schedule next timer. + pb.timer = setTimeout(function () { pb.sendPing(); }, pb.delay); + }, + error: function (xmlhttp) { + var e = new Drupal.AjaxError(xmlhttp, pb.uri); + pb.displayError('
    ' + e.message + '
    '); + } + }); + } + }, + + /** + * Display errors on the page. + * + * @param {string} string + * The error message to show the user. + */ + displayError: function (string) { + var error = $('
    ').html(string); + $(this.element).before(error).hide(); + + if (this.errorCallback) { + this.errorCallback(this); + } + } + }); + +})(jQuery, Drupal); diff --git a/core/misc/states.es6.js b/core/misc/states.es6.js new file mode 100644 index 0000000000..24374b625f --- /dev/null +++ b/core/misc/states.es6.js @@ -0,0 +1,724 @@ +/** + * @file + * Drupal's states library. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * The base States namespace. + * + * Having the local states variable allows us to use the States namespace + * without having to always declare "Drupal.states". + * + * @namespace Drupal.states + */ + var states = Drupal.states = { + + /** + * An array of functions that should be postponed. + */ + postponed: [] + }; + + /** + * Attaches the states. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches states behaviors. + */ + Drupal.behaviors.states = { + attach: function (context, settings) { + var $states = $(context).find('[data-drupal-states]'); + var config; + var state; + var il = $states.length; + for (var i = 0; i < il; i++) { + config = JSON.parse($states[i].getAttribute('data-drupal-states')); + for (state in config) { + if (config.hasOwnProperty(state)) { + new states.Dependent({ + element: $($states[i]), + state: states.State.sanitize(state), + constraints: config[state] + }); + } + } + } + + // Execute all postponed functions now. + while (states.postponed.length) { + (states.postponed.shift())(); + } + } + }; + + /** + * Object representing an element that depends on other elements. + * + * @constructor Drupal.states.Dependent + * + * @param {object} args + * Object with the following keys (all of which are required) + * @param {jQuery} args.element + * A jQuery object of the dependent element + * @param {Drupal.states.State} args.state + * A State object describing the state that is dependent + * @param {object} args.constraints + * An object with dependency specifications. Lists all elements that this + * element depends on. It can be nested and can contain + * arbitrary AND and OR clauses. + */ + states.Dependent = function (args) { + $.extend(this, {values: {}, oldValue: null}, args); + + this.dependees = this.getDependees(); + for (var selector in this.dependees) { + if (this.dependees.hasOwnProperty(selector)) { + this.initializeDependee(selector, this.dependees[selector]); + } + } + }; + + /** + * Comparison functions for comparing the value of an element with the + * specification from the dependency settings. If the object type can't be + * found in this list, the === operator is used by default. + * + * @name Drupal.states.Dependent.comparisons + * + * @prop {function} RegExp + * @prop {function} Function + * @prop {function} Number + */ + states.Dependent.comparisons = { + RegExp: function (reference, value) { + return reference.test(value); + }, + Function: function (reference, value) { + // The "reference" variable is a comparison function. + return reference(value); + }, + Number: function (reference, value) { + // If "reference" is a number and "value" is a string, then cast + // reference as a string before applying the strict comparison in + // compare(). + // Otherwise numeric keys in the form's #states array fail to match + // string values returned from jQuery's val(). + return (typeof value === 'string') ? compare(reference.toString(), value) : compare(reference, value); + } + }; + + states.Dependent.prototype = { + + /** + * Initializes one of the elements this dependent depends on. + * + * @memberof Drupal.states.Dependent# + * + * @param {string} selector + * The CSS selector describing the dependee. + * @param {object} dependeeStates + * The list of states that have to be monitored for tracking the + * dependee's compliance status. + */ + initializeDependee: function (selector, dependeeStates) { + var state; + var self = this; + + function stateEventHandler(e) { + self.update(e.data.selector, e.data.state, e.value); + } + + // Cache for the states of this dependee. + this.values[selector] = {}; + + for (var i in dependeeStates) { + if (dependeeStates.hasOwnProperty(i)) { + state = dependeeStates[i]; + // Make sure we're not initializing this selector/state combination + // twice. + if ($.inArray(state, dependeeStates) === -1) { + continue; + } + + state = states.State.sanitize(state); + + // Initialize the value of this state. + this.values[selector][state.name] = null; + + // Monitor state changes of the specified state for this dependee. + $(selector).on('state:' + state, {selector: selector, state: state}, stateEventHandler); + + // Make sure the event we just bound ourselves to is actually fired. + new states.Trigger({selector: selector, state: state}); + } + } + }, + + /** + * Compares a value with a reference value. + * + * @memberof Drupal.states.Dependent# + * + * @param {object} reference + * The value used for reference. + * @param {string} selector + * CSS selector describing the dependee. + * @param {Drupal.states.State} state + * A State object describing the dependee's updated state. + * + * @return {bool} + * true or false. + */ + compare: function (reference, selector, state) { + var value = this.values[selector][state.name]; + if (reference.constructor.name in states.Dependent.comparisons) { + // Use a custom compare function for certain reference value types. + return states.Dependent.comparisons[reference.constructor.name](reference, value); + } + else { + // Do a plain comparison otherwise. + return compare(reference, value); + } + }, + + /** + * Update the value of a dependee's state. + * + * @memberof Drupal.states.Dependent# + * + * @param {string} selector + * CSS selector describing the dependee. + * @param {Drupal.states.state} state + * A State object describing the dependee's updated state. + * @param {string} value + * The new value for the dependee's updated state. + */ + update: function (selector, state, value) { + // Only act when the 'new' value is actually new. + if (value !== this.values[selector][state.name]) { + this.values[selector][state.name] = value; + this.reevaluate(); + } + }, + + /** + * Triggers change events in case a state changed. + * + * @memberof Drupal.states.Dependent# + */ + reevaluate: function () { + // Check whether any constraint for this dependent state is satisfied. + var value = this.verifyConstraints(this.constraints); + + // Only invoke a state change event when the value actually changed. + if (value !== this.oldValue) { + // Store the new value so that we can compare later whether the value + // actually changed. + this.oldValue = value; + + // Normalize the value to match the normalized state name. + value = invert(value, this.state.invert); + + // By adding "trigger: true", we ensure that state changes don't go into + // infinite loops. + this.element.trigger({type: 'state:' + this.state, value: value, trigger: true}); + } + }, + + /** + * Evaluates child constraints to determine if a constraint is satisfied. + * + * @memberof Drupal.states.Dependent# + * + * @param {object|Array} constraints + * A constraint object or an array of constraints. + * @param {string} selector + * The selector for these constraints. If undefined, there isn't yet a + * selector that these constraints apply to. In that case, the keys of the + * object are interpreted as the selector if encountered. + * + * @return {bool} + * true or false, depending on whether these constraints are satisfied. + */ + verifyConstraints: function (constraints, selector) { + var result; + if ($.isArray(constraints)) { + // This constraint is an array (OR or XOR). + var hasXor = $.inArray('xor', constraints) === -1; + var len = constraints.length; + for (var i = 0; i < len; i++) { + if (constraints[i] !== 'xor') { + var constraint = this.checkConstraints(constraints[i], selector, i); + // Return if this is OR and we have a satisfied constraint or if + // this is XOR and we have a second satisfied constraint. + if (constraint && (hasXor || result)) { + return hasXor; + } + result = result || constraint; + } + } + } + // Make sure we don't try to iterate over things other than objects. This + // shouldn't normally occur, but in case the condition definition is + // bogus, we don't want to end up with an infinite loop. + else if ($.isPlainObject(constraints)) { + // This constraint is an object (AND). + for (var n in constraints) { + if (constraints.hasOwnProperty(n)) { + result = ternary(result, this.checkConstraints(constraints[n], selector, n)); + // False and anything else will evaluate to false, so return when + // any false condition is found. + if (result === false) { return false; } + } + } + } + return result; + }, + + /** + * Checks whether the value matches the requirements for this constraint. + * + * @memberof Drupal.states.Dependent# + * + * @param {string|Array|object} value + * Either the value of a state or an array/object of constraints. In the + * latter case, resolving the constraint continues. + * @param {string} [selector] + * The selector for this constraint. If undefined, there isn't yet a + * selector that this constraint applies to. In that case, the state key + * is propagates to a selector and resolving continues. + * @param {Drupal.states.State} [state] + * The state to check for this constraint. If undefined, resolving + * continues. If both selector and state aren't undefined and valid + * non-numeric strings, a lookup for the actual value of that selector's + * state is performed. This parameter is not a State object but a pristine + * state string. + * + * @return {bool} + * true or false, depending on whether this constraint is satisfied. + */ + checkConstraints: function (value, selector, state) { + // Normalize the last parameter. If it's non-numeric, we treat it either + // as a selector (in case there isn't one yet) or as a trigger/state. + if (typeof state !== 'string' || (/[0-9]/).test(state[0])) { + state = null; + } + else if (typeof selector === 'undefined') { + // Propagate the state to the selector when there isn't one yet. + selector = state; + state = null; + } + + if (state !== null) { + // Constraints is the actual constraints of an element to check for. + state = states.State.sanitize(state); + return invert(this.compare(value, selector, state), state.invert); + } + else { + // Resolve this constraint as an AND/OR operator. + return this.verifyConstraints(value, selector); + } + }, + + /** + * Gathers information about all required triggers. + * + * @memberof Drupal.states.Dependent# + * + * @return {object} + * An object describing the required triggers. + */ + getDependees: function () { + var cache = {}; + // Swivel the lookup function so that we can record all available + // selector- state combinations for initialization. + var _compare = this.compare; + this.compare = function (reference, selector, state) { + (cache[selector] || (cache[selector] = [])).push(state.name); + // Return nothing (=== undefined) so that the constraint loops are not + // broken. + }; + + // This call doesn't actually verify anything but uses the resolving + // mechanism to go through the constraints array, trying to look up each + // value. Since we swivelled the compare function, this comparison returns + // undefined and lookup continues until the very end. Instead of lookup up + // the value, we record that combination of selector and state so that we + // can initialize all triggers. + this.verifyConstraints(this.constraints); + // Restore the original function. + this.compare = _compare; + + return cache; + } + }; + + /** + * @constructor Drupal.states.Trigger + * + * @param {object} args + * Trigger arguments. + */ + states.Trigger = function (args) { + $.extend(this, args); + + if (this.state in states.Trigger.states) { + this.element = $(this.selector); + + // Only call the trigger initializer when it wasn't yet attached to this + // element. Otherwise we'd end up with duplicate events. + if (!this.element.data('trigger:' + this.state)) { + this.initialize(); + } + } + }; + + states.Trigger.prototype = { + + /** + * @memberof Drupal.states.Trigger# + */ + initialize: function () { + var trigger = states.Trigger.states[this.state]; + + if (typeof trigger === 'function') { + // We have a custom trigger initialization function. + trigger.call(window, this.element); + } + else { + for (var event in trigger) { + if (trigger.hasOwnProperty(event)) { + this.defaultTrigger(event, trigger[event]); + } + } + } + + // Mark this trigger as initialized for this element. + this.element.data('trigger:' + this.state, true); + }, + + /** + * @memberof Drupal.states.Trigger# + * + * @param {jQuery.Event} event + * The event triggered. + * @param {function} valueFn + * The function to call. + */ + defaultTrigger: function (event, valueFn) { + var oldValue = valueFn.call(this.element); + + // Attach the event callback. + this.element.on(event, $.proxy(function (e) { + var value = valueFn.call(this.element, e); + // Only trigger the event if the value has actually changed. + if (oldValue !== value) { + this.element.trigger({type: 'state:' + this.state, value: value, oldValue: oldValue}); + oldValue = value; + } + }, this)); + + states.postponed.push($.proxy(function () { + // Trigger the event once for initialization purposes. + this.element.trigger({type: 'state:' + this.state, value: oldValue, oldValue: null}); + }, this)); + } + }; + + /** + * This list of states contains functions that are used to monitor the state + * of an element. Whenever an element depends on the state of another element, + * one of these trigger functions is added to the dependee so that the + * dependent element can be updated. + * + * @name Drupal.states.Trigger.states + * + * @prop empty + * @prop checked + * @prop value + * @prop collapsed + */ + states.Trigger.states = { + // 'empty' describes the state to be monitored. + empty: { + // 'keyup' is the (native DOM) event that we watch for. + keyup: function () { + // The function associated with that trigger returns the new value for + // the state. + return this.val() === ''; + } + }, + + checked: { + change: function () { + // prop() and attr() only takes the first element into account. To + // support selectors matching multiple checkboxes, iterate over all and + // return whether any is checked. + var checked = false; + this.each(function () { + // Use prop() here as we want a boolean of the checkbox state. + // @see http://api.jquery.com/prop/ + checked = $(this).prop('checked'); + // Break the each() loop if this is checked. + return !checked; + }); + return checked; + } + }, + + // For radio buttons, only return the value if the radio button is selected. + value: { + keyup: function () { + // Radio buttons share the same :input[name="key"] selector. + if (this.length > 1) { + // Initial checked value of radios is undefined, so we return false. + return this.filter(':checked').val() || false; + } + return this.val(); + }, + change: function () { + // Radio buttons share the same :input[name="key"] selector. + if (this.length > 1) { + // Initial checked value of radios is undefined, so we return false. + return this.filter(':checked').val() || false; + } + return this.val(); + } + }, + + collapsed: { + collapsed: function (e) { + return (typeof e !== 'undefined' && 'value' in e) ? e.value : !this.is('[open]'); + } + } + }; + + /** + * A state object is used for describing the state and performing aliasing. + * + * @constructor Drupal.states.State + * + * @param {string} state + * The name of the state. + */ + states.State = function (state) { + + /** + * Original unresolved name. + */ + this.pristine = this.name = state; + + // Normalize the state name. + var process = true; + do { + // Iteratively remove exclamation marks and invert the value. + while (this.name.charAt(0) === '!') { + this.name = this.name.substring(1); + this.invert = !this.invert; + } + + // Replace the state with its normalized name. + if (this.name in states.State.aliases) { + this.name = states.State.aliases[this.name]; + } + else { + process = false; + } + } while (process); + }; + + /** + * Creates a new State object by sanitizing the passed value. + * + * @name Drupal.states.State.sanitize + * + * @param {string|Drupal.states.State} state + * A state object or the name of a state. + * + * @return {Drupal.states.state} + * A state object. + */ + states.State.sanitize = function (state) { + if (state instanceof states.State) { + return state; + } + else { + return new states.State(state); + } + }; + + /** + * This list of aliases is used to normalize states and associates negated + * names with their respective inverse state. + * + * @name Drupal.states.State.aliases + */ + states.State.aliases = { + enabled: '!disabled', + invisible: '!visible', + invalid: '!valid', + untouched: '!touched', + optional: '!required', + filled: '!empty', + unchecked: '!checked', + irrelevant: '!relevant', + expanded: '!collapsed', + open: '!collapsed', + closed: 'collapsed', + readwrite: '!readonly' + }; + + states.State.prototype = { + + /** + * @memberof Drupal.states.State# + */ + invert: false, + + /** + * Ensures that just using the state object returns the name. + * + * @memberof Drupal.states.State# + * + * @return {string} + * The name of the state. + */ + toString: function () { + return this.name; + } + }; + + /** + * Global state change handlers. These are bound to "document" to cover all + * elements whose state changes. Events sent to elements within the page + * bubble up to these handlers. We use this system so that themes and modules + * can override these state change handlers for particular parts of a page. + */ + + var $document = $(document); + $document.on('state:disabled', function (e) { + // Only act when this change was triggered by a dependency and not by the + // element monitoring itself. + if (e.trigger) { + $(e.target) + .prop('disabled', e.value) + .closest('.js-form-item, .js-form-submit, .js-form-wrapper').toggleClass('form-disabled', e.value) + .find('select, input, textarea').prop('disabled', e.value); + + // Note: WebKit nightlies don't reflect that change correctly. + // See https://bugs.webkit.org/show_bug.cgi?id=23789 + } + }); + + $document.on('state:required', function (e) { + if (e.trigger) { + if (e.value) { + var label = 'label' + (e.target.id ? '[for=' + e.target.id + ']' : ''); + var $label = $(e.target).attr({'required': 'required', 'aria-required': 'aria-required'}).closest('.js-form-item, .js-form-wrapper').find(label); + // Avoids duplicate required markers on initialization. + if (!$label.hasClass('js-form-required').length) { + $label.addClass('js-form-required form-required'); + } + } + else { + $(e.target).removeAttr('required aria-required').closest('.js-form-item, .js-form-wrapper').find('label.js-form-required').removeClass('js-form-required form-required'); + } + } + }); + + $document.on('state:visible', function (e) { + if (e.trigger) { + $(e.target).closest('.js-form-item, .js-form-submit, .js-form-wrapper').toggle(e.value); + } + }); + + $document.on('state:checked', function (e) { + if (e.trigger) { + $(e.target).prop('checked', e.value); + } + }); + + $document.on('state:collapsed', function (e) { + if (e.trigger) { + if ($(e.target).is('[open]') === e.value) { + $(e.target).find('> summary').trigger('click'); + } + } + }); + + /** + * These are helper functions implementing addition "operators" and don't + * implement any logic that is particular to states. + */ + + /** + * Bitwise AND with a third undefined state. + * + * @function Drupal.states~ternary + * + * @param {*} a + * Value a. + * @param {*} b + * Value b + * + * @return {bool} + * The result. + */ + function ternary(a, b) { + if (typeof a === 'undefined') { + return b; + } + else if (typeof b === 'undefined') { + return a; + } + else { + return a && b; + } + } + + /** + * Inverts a (if it's not undefined) when invertState is true. + * + * @function Drupal.states~invert + * + * @param {*} a + * The value to maybe invert. + * @param {bool} invertState + * Whether to invert state or not. + * + * @return {bool} + * The result. + */ + function invert(a, invertState) { + return (invertState && typeof a !== 'undefined') ? !a : a; + } + + /** + * Compares two values while ignoring undefined values. + * + * @function Drupal.states~compare + * + * @param {*} a + * Value a. + * @param {*} b + * Value b. + * + * @return {bool} + * The comparison result. + */ + function compare(a, b) { + if (a === b) { + return typeof a === 'undefined' ? a : true; + } + else { + return typeof a === 'undefined' || typeof b === 'undefined'; + } + } + +})(jQuery, Drupal); diff --git a/core/misc/tabbingmanager.es6.js b/core/misc/tabbingmanager.es6.js new file mode 100644 index 0000000000..134523f5bf --- /dev/null +++ b/core/misc/tabbingmanager.es6.js @@ -0,0 +1,369 @@ +/** + * @file + * Manages page tabbing modifications made by modules. + */ + +/** + * Allow modules to respond to the constrain event. + * + * @event drupalTabbingConstrained + */ + +/** + * Allow modules to respond to the tabbingContext release event. + * + * @event drupalTabbingContextReleased + */ + +/** + * Allow modules to respond to the constrain event. + * + * @event drupalTabbingContextActivated + */ + +/** + * Allow modules to respond to the constrain event. + * + * @event drupalTabbingContextDeactivated + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Provides an API for managing page tabbing order modifications. + * + * @constructor Drupal~TabbingManager + */ + function TabbingManager() { + + /** + * Tabbing sets are stored as a stack. The active set is at the top of the + * stack. We use a JavaScript array as if it were a stack; we consider the + * first element to be the bottom and the last element to be the top. This + * allows us to use JavaScript's built-in Array.push() and Array.pop() + * methods. + * + * @type {Array.} + */ + this.stack = []; + } + + /** + * Add public methods to the TabbingManager class. + */ + $.extend(TabbingManager.prototype, /** @lends Drupal~TabbingManager# */{ + + /** + * Constrain tabbing to the specified set of elements only. + * + * Makes elements outside of the specified set of elements unreachable via + * the tab key. + * + * @param {jQuery} elements + * The set of elements to which tabbing should be constrained. Can also + * be a jQuery-compatible selector string. + * + * @return {Drupal~TabbingContext} + * The TabbingContext instance. + * + * @fires event:drupalTabbingConstrained + */ + constrain: function (elements) { + // Deactivate all tabbingContexts to prepare for the new constraint. A + // tabbingContext instance will only be reactivated if the stack is + // unwound to it in the _unwindStack() method. + var il = this.stack.length; + for (var i = 0; i < il; i++) { + this.stack[i].deactivate(); + } + + // The "active tabbing set" are the elements tabbing should be constrained + // to. + var $elements = $(elements).find(':tabbable').addBack(':tabbable'); + + var tabbingContext = new TabbingContext({ + // The level is the current height of the stack before this new + // tabbingContext is pushed on top of the stack. + level: this.stack.length, + $tabbableElements: $elements + }); + + this.stack.push(tabbingContext); + + // Activates the tabbingContext; this will manipulate the DOM to constrain + // tabbing. + tabbingContext.activate(); + + // Allow modules to respond to the constrain event. + $(document).trigger('drupalTabbingConstrained', tabbingContext); + + return tabbingContext; + }, + + /** + * Restores a former tabbingContext when an active one is released. + * + * The TabbingManager stack of tabbingContext instances will be unwound + * from the top-most released tabbingContext down to the first non-released + * tabbingContext instance. This non-released instance is then activated. + */ + release: function () { + // Unwind as far as possible: find the topmost non-released + // tabbingContext. + var toActivate = this.stack.length - 1; + while (toActivate >= 0 && this.stack[toActivate].released) { + toActivate--; + } + + // Delete all tabbingContexts after the to be activated one. They have + // already been deactivated, so their effect on the DOM has been reversed. + this.stack.splice(toActivate + 1); + + // Get topmost tabbingContext, if one exists, and activate it. + if (toActivate >= 0) { + this.stack[toActivate].activate(); + } + }, + + /** + * Makes all elements outside of the tabbingContext's set untabbable. + * + * Elements made untabbable have their original tabindex and autofocus + * values stored so that they might be restored later when this + * tabbingContext is deactivated. + * + * @param {Drupal~TabbingContext} tabbingContext + * The TabbingContext instance that has been activated. + */ + activate: function (tabbingContext) { + var $set = tabbingContext.$tabbableElements; + var level = tabbingContext.level; + // Determine which elements are reachable via tabbing by default. + var $disabledSet = $(':tabbable') + // Exclude elements of the active tabbing set. + .not($set); + // Set the disabled set on the tabbingContext. + tabbingContext.$disabledElements = $disabledSet; + // Record the tabindex for each element, so we can restore it later. + var il = $disabledSet.length; + for (var i = 0; i < il; i++) { + this.recordTabindex($disabledSet.eq(i), level); + } + // Make all tabbable elements outside of the active tabbing set + // unreachable. + $disabledSet + .prop('tabindex', -1) + .prop('autofocus', false); + + // Set focus on an element in the tabbingContext's set of tabbable + // elements. First, check if there is an element with an autofocus + // attribute. Select the last one from the DOM order. + var $hasFocus = $set.filter('[autofocus]').eq(-1); + // If no element in the tabbable set has an autofocus attribute, select + // the first element in the set. + if ($hasFocus.length === 0) { + $hasFocus = $set.eq(0); + } + $hasFocus.trigger('focus'); + }, + + /** + * Restores that tabbable state of a tabbingContext's disabled elements. + * + * Elements that were made untabbable have their original tabindex and + * autofocus values restored. + * + * @param {Drupal~TabbingContext} tabbingContext + * The TabbingContext instance that has been deactivated. + */ + deactivate: function (tabbingContext) { + var $set = tabbingContext.$disabledElements; + var level = tabbingContext.level; + var il = $set.length; + for (var i = 0; i < il; i++) { + this.restoreTabindex($set.eq(i), level); + } + }, + + /** + * Records the tabindex and autofocus values of an untabbable element. + * + * @param {jQuery} $el + * The set of elements that have been disabled. + * @param {number} level + * The stack level for which the tabindex attribute should be recorded. + */ + recordTabindex: function ($el, level) { + var tabInfo = $el.data('drupalOriginalTabIndices') || {}; + tabInfo[level] = { + tabindex: $el[0].getAttribute('tabindex'), + autofocus: $el[0].hasAttribute('autofocus') + }; + $el.data('drupalOriginalTabIndices', tabInfo); + }, + + /** + * Restores the tabindex and autofocus values of a reactivated element. + * + * @param {jQuery} $el + * The element that is being reactivated. + * @param {number} level + * The stack level for which the tabindex attribute should be restored. + */ + restoreTabindex: function ($el, level) { + var tabInfo = $el.data('drupalOriginalTabIndices'); + if (tabInfo && tabInfo[level]) { + var data = tabInfo[level]; + if (data.tabindex) { + $el[0].setAttribute('tabindex', data.tabindex); + } + // If the element did not have a tabindex at this stack level then + // remove it. + else { + $el[0].removeAttribute('tabindex'); + } + if (data.autofocus) { + $el[0].setAttribute('autofocus', 'autofocus'); + } + + // Clean up $.data. + if (level === 0) { + // Remove all data. + $el.removeData('drupalOriginalTabIndices'); + } + else { + // Remove the data for this stack level and higher. + var levelToDelete = level; + while (tabInfo.hasOwnProperty(levelToDelete)) { + delete tabInfo[levelToDelete]; + levelToDelete++; + } + $el.data('drupalOriginalTabIndices', tabInfo); + } + } + } + }); + + /** + * Stores a set of tabbable elements. + * + * This constraint can be removed with the release() method. + * + * @constructor Drupal~TabbingContext + * + * @param {object} options + * A set of initiating values + * @param {number} options.level + * The level in the TabbingManager's stack of this tabbingContext. + * @param {jQuery} options.$tabbableElements + * The DOM elements that should be reachable via the tab key when this + * tabbingContext is active. + * @param {jQuery} options.$disabledElements + * The DOM elements that should not be reachable via the tab key when this + * tabbingContext is active. + * @param {bool} options.released + * A released tabbingContext can never be activated again. It will be + * cleaned up when the TabbingManager unwinds its stack. + * @param {bool} options.active + * When true, the tabbable elements of this tabbingContext will be reachable + * via the tab key and the disabled elements will not. Only one + * tabbingContext can be active at a time. + */ + function TabbingContext(options) { + + $.extend(this, /** @lends Drupal~TabbingContext# */{ + + /** + * @type {?number} + */ + level: null, + + /** + * @type {jQuery} + */ + $tabbableElements: $(), + + /** + * @type {jQuery} + */ + $disabledElements: $(), + + /** + * @type {bool} + */ + released: false, + + /** + * @type {bool} + */ + active: false + }, options); + } + + /** + * Add public methods to the TabbingContext class. + */ + $.extend(TabbingContext.prototype, /** @lends Drupal~TabbingContext# */{ + + /** + * Releases this TabbingContext. + * + * Once a TabbingContext object is released, it can never be activated + * again. + * + * @fires event:drupalTabbingContextReleased + */ + release: function () { + if (!this.released) { + this.deactivate(); + this.released = true; + Drupal.tabbingManager.release(this); + // Allow modules to respond to the tabbingContext release event. + $(document).trigger('drupalTabbingContextReleased', this); + } + }, + + /** + * Activates this TabbingContext. + * + * @fires event:drupalTabbingContextActivated + */ + activate: function () { + // A released TabbingContext object can never be activated again. + if (!this.active && !this.released) { + this.active = true; + Drupal.tabbingManager.activate(this); + // Allow modules to respond to the constrain event. + $(document).trigger('drupalTabbingContextActivated', this); + } + }, + + /** + * Deactivates this TabbingContext. + * + * @fires event:drupalTabbingContextDeactivated + */ + deactivate: function () { + if (this.active) { + this.active = false; + Drupal.tabbingManager.deactivate(this); + // Allow modules to respond to the constrain event. + $(document).trigger('drupalTabbingContextDeactivated', this); + } + } + }); + + // Mark this behavior as processed on the first pass and return if it is + // already processed. + if (Drupal.tabbingManager) { + return; + } + + /** + * @type {Drupal~TabbingManager} + */ + Drupal.tabbingManager = new TabbingManager(); + +}(jQuery, Drupal)); diff --git a/core/misc/tabledrag.es6.js b/core/misc/tabledrag.es6.js new file mode 100644 index 0000000000..75468e60dd --- /dev/null +++ b/core/misc/tabledrag.es6.js @@ -0,0 +1,1557 @@ +/** + * @file + * Provide dragging capabilities to admin uis. + */ + +/** + * Triggers when weights columns are toggled. + * + * @event columnschange + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Store the state of weight columns display for all tables. + * + * Default value is to hide weight columns. + */ + var showWeight = JSON.parse(localStorage.getItem('Drupal.tableDrag.showWeight')); + + /** + * Drag and drop table rows with field manipulation. + * + * Using the drupal_attach_tabledrag() function, any table with weights or + * parent relationships may be made into draggable tables. Columns containing + * a field may optionally be hidden, providing a better user experience. + * + * Created tableDrag instances may be modified with custom behaviors by + * overriding the .onDrag, .onDrop, .row.onSwap, and .row.onIndent methods. + * See blocks.js for an example of adding additional functionality to + * tableDrag. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.tableDrag = { + attach: function (context, settings) { + function initTableDrag(table, base) { + if (table.length) { + // Create the new tableDrag instance. Save in the Drupal variable + // to allow other scripts access to the object. + Drupal.tableDrag[base] = new Drupal.tableDrag(table[0], settings.tableDrag[base]); + } + } + + for (var base in settings.tableDrag) { + if (settings.tableDrag.hasOwnProperty(base)) { + initTableDrag($(context).find('#' + base).once('tabledrag'), base); + } + } + } + }; + + /** + * Provides table and field manipulation. + * + * @constructor + * + * @param {HTMLElement} table + * DOM object for the table to be made draggable. + * @param {object} tableSettings + * Settings for the table added via drupal_add_dragtable(). + */ + Drupal.tableDrag = function (table, tableSettings) { + var self = this; + var $table = $(table); + + /** + * @type {jQuery} + */ + this.$table = $(table); + + /** + * + * @type {HTMLElement} + */ + this.table = table; + + /** + * @type {object} + */ + this.tableSettings = tableSettings; + + /** + * Used to hold information about a current drag operation. + * + * @type {?HTMLElement} + */ + this.dragObject = null; + + /** + * Provides operations for row manipulation. + * + * @type {?HTMLElement} + */ + this.rowObject = null; + + /** + * Remember the previous element. + * + * @type {?HTMLElement} + */ + this.oldRowElement = null; + + /** + * Used to determine up or down direction from last mouse move. + * + * @type {number} + */ + this.oldY = 0; + + /** + * Whether anything in the entire table has changed. + * + * @type {bool} + */ + this.changed = false; + + /** + * Maximum amount of allowed parenting. + * + * @type {number} + */ + this.maxDepth = 0; + + /** + * Direction of the table. + * + * @type {number} + */ + this.rtl = $(this.table).css('direction') === 'rtl' ? -1 : 1; + + /** + * + * @type {bool} + */ + this.striping = $(this.table).data('striping') === 1; + + /** + * Configure the scroll settings. + * + * @type {object} + * + * @prop {number} amount + * @prop {number} interval + * @prop {number} trigger + */ + this.scrollSettings = {amount: 4, interval: 50, trigger: 70}; + + /** + * + * @type {?number} + */ + this.scrollInterval = null; + + /** + * + * @type {number} + */ + this.scrollY = 0; + + /** + * + * @type {number} + */ + this.windowHeight = 0; + + /** + * Check this table's settings for parent relationships. + * + * For efficiency, large sections of code can be skipped if we don't need to + * track horizontal movement and indentations. + * + * @type {bool} + */ + this.indentEnabled = false; + for (var group in tableSettings) { + if (tableSettings.hasOwnProperty(group)) { + for (var n in tableSettings[group]) { + if (tableSettings[group].hasOwnProperty(n)) { + if (tableSettings[group][n].relationship === 'parent') { + this.indentEnabled = true; + } + if (tableSettings[group][n].limit > 0) { + this.maxDepth = tableSettings[group][n].limit; + } + } + } + } + } + if (this.indentEnabled) { + + /** + * Total width of indents, set in makeDraggable. + * + * @type {number} + */ + this.indentCount = 1; + // Find the width of indentations to measure mouse movements against. + // Because the table doesn't need to start with any indentations, we + // manually append 2 indentations in the first draggable row, measure + // the offset, then remove. + var indent = Drupal.theme('tableDragIndentation'); + var testRow = $('').addClass('draggable').appendTo(table); + var testCell = $('
    To retrieve the HTML for text that should be emphasized and + * displayed as a placeholder inside a sentence.
    ').appendTo(testRow).prepend(indent).prepend(indent); + var $indentation = testCell.find('.js-indentation'); + + /** + * + * @type {number} + */ + this.indentAmount = $indentation.get(1).offsetLeft - $indentation.get(0).offsetLeft; + testRow.remove(); + } + + // Make each applicable row draggable. + // Match immediate children of the parent element to allow nesting. + $table.find('> tr.draggable, > tbody > tr.draggable').each(function () { self.makeDraggable(this); }); + + // Add a link before the table for users to show or hide weight columns. + $table.before($('') + .attr('title', Drupal.t('Re-order rows by numerical weight instead of dragging.')) + .on('click', $.proxy(function (e) { + e.preventDefault(); + this.toggleColumns(); + }, this)) + .wrap('
    ') + .parent() + ); + + // Initialize the specified columns (for example, weight or parent columns) + // to show or hide according to user preference. This aids accessibility + // so that, e.g., screen reader users can choose to enter weight values and + // manipulate form elements directly, rather than using drag-and-drop.. + self.initColumns(); + + // Add event bindings to the document. The self variable is passed along + // as event handlers do not have direct access to the tableDrag object. + $(document).on('touchmove', function (event) { return self.dragRow(event.originalEvent.touches[0], self); }); + $(document).on('touchend', function (event) { return self.dropRow(event.originalEvent.touches[0], self); }); + $(document).on('mousemove pointermove', function (event) { return self.dragRow(event, self); }); + $(document).on('mouseup pointerup', function (event) { return self.dropRow(event, self); }); + + // React to localStorage event showing or hiding weight columns. + $(window).on('storage', $.proxy(function (e) { + // Only react to 'Drupal.tableDrag.showWeight' value change. + if (e.originalEvent.key === 'Drupal.tableDrag.showWeight') { + // This was changed in another window, get the new value for this + // window. + showWeight = JSON.parse(e.originalEvent.newValue); + this.displayColumns(showWeight); + } + }, this)); + }; + + /** + * Initialize columns containing form elements to be hidden by default. + * + * Identify and mark each cell with a CSS class so we can easily toggle + * show/hide it. Finally, hide columns if user does not have a + * 'Drupal.tableDrag.showWeight' localStorage value. + */ + Drupal.tableDrag.prototype.initColumns = function () { + var $table = this.$table; + var hidden; + var cell; + var columnIndex; + for (var group in this.tableSettings) { + if (this.tableSettings.hasOwnProperty(group)) { + + // Find the first field in this group. + for (var d in this.tableSettings[group]) { + if (this.tableSettings[group].hasOwnProperty(d)) { + var field = $table.find('.' + this.tableSettings[group][d].target).eq(0); + if (field.length && this.tableSettings[group][d].hidden) { + hidden = this.tableSettings[group][d].hidden; + cell = field.closest('td'); + break; + } + } + } + + // Mark the column containing this field so it can be hidden. + if (hidden && cell[0]) { + // Add 1 to our indexes. The nth-child selector is 1 based, not 0 + // based. Match immediate children of the parent element to allow + // nesting. + columnIndex = cell.parent().find('> td').index(cell.get(0)) + 1; + $table.find('> thead > tr, > tbody > tr, > tr').each(this.addColspanClass(columnIndex)); + } + } + } + this.displayColumns(showWeight); + }; + + /** + * Mark cells that have colspan. + * + * In order to adjust the colspan instead of hiding them altogether. + * + * @param {number} columnIndex + * The column index to add colspan class to. + * + * @return {function} + * Function to add colspan class. + */ + Drupal.tableDrag.prototype.addColspanClass = function (columnIndex) { + return function () { + // Get the columnIndex and adjust for any colspans in this row. + var $row = $(this); + var index = columnIndex; + var cells = $row.children(); + var cell; + cells.each(function (n) { + if (n < index && this.colSpan && this.colSpan > 1) { + index -= this.colSpan - 1; + } + }); + if (index > 0) { + cell = cells.filter(':nth-child(' + index + ')'); + if (cell[0].colSpan && cell[0].colSpan > 1) { + // If this cell has a colspan, mark it so we can reduce the colspan. + cell.addClass('tabledrag-has-colspan'); + } + else { + // Mark this cell so we can hide it. + cell.addClass('tabledrag-hide'); + } + } + }; + }; + + /** + * Hide or display weight columns. Triggers an event on change. + * + * @fires event:columnschange + * + * @param {bool} displayWeight + * 'true' will show weight columns. + */ + Drupal.tableDrag.prototype.displayColumns = function (displayWeight) { + if (displayWeight) { + this.showColumns(); + } + // Default action is to hide columns. + else { + this.hideColumns(); + } + // Trigger an event to allow other scripts to react to this display change. + // Force the extra parameter as a bool. + $('table').findOnce('tabledrag').trigger('columnschange', !!displayWeight); + }; + + /** + * Toggle the weight column depending on 'showWeight' value. + * + * Store only default override. + */ + Drupal.tableDrag.prototype.toggleColumns = function () { + showWeight = !showWeight; + this.displayColumns(showWeight); + if (showWeight) { + // Save default override. + localStorage.setItem('Drupal.tableDrag.showWeight', showWeight); + } + else { + // Reset the value to its default. + localStorage.removeItem('Drupal.tableDrag.showWeight'); + } + }; + + /** + * Hide the columns containing weight/parent form elements. + * + * Undo showColumns(). + */ + Drupal.tableDrag.prototype.hideColumns = function () { + var $tables = $('table').findOnce('tabledrag'); + // Hide weight/parent cells and headers. + $tables.find('.tabledrag-hide').css('display', 'none'); + // Show TableDrag handles. + $tables.find('.tabledrag-handle').css('display', ''); + // Reduce the colspan of any effected multi-span columns. + $tables.find('.tabledrag-has-colspan').each(function () { + this.colSpan = this.colSpan - 1; + }); + // Change link text. + $('.tabledrag-toggle-weight').text(Drupal.t('Show row weights')); + }; + + /** + * Show the columns containing weight/parent form elements. + * + * Undo hideColumns(). + */ + Drupal.tableDrag.prototype.showColumns = function () { + var $tables = $('table').findOnce('tabledrag'); + // Show weight/parent cells and headers. + $tables.find('.tabledrag-hide').css('display', ''); + // Hide TableDrag handles. + $tables.find('.tabledrag-handle').css('display', 'none'); + // Increase the colspan for any columns where it was previously reduced. + $tables.find('.tabledrag-has-colspan').each(function () { + this.colSpan = this.colSpan + 1; + }); + // Change link text. + $('.tabledrag-toggle-weight').text(Drupal.t('Hide row weights')); + }; + + /** + * Find the target used within a particular row and group. + * + * @param {string} group + * Group selector. + * @param {HTMLElement} row + * The row HTML element. + * + * @return {object} + * The table row settings. + */ + Drupal.tableDrag.prototype.rowSettings = function (group, row) { + var field = $(row).find('.' + group); + var tableSettingsGroup = this.tableSettings[group]; + for (var delta in tableSettingsGroup) { + if (tableSettingsGroup.hasOwnProperty(delta)) { + var targetClass = tableSettingsGroup[delta].target; + if (field.is('.' + targetClass)) { + // Return a copy of the row settings. + var rowSettings = {}; + for (var n in tableSettingsGroup[delta]) { + if (tableSettingsGroup[delta].hasOwnProperty(n)) { + rowSettings[n] = tableSettingsGroup[delta][n]; + } + } + return rowSettings; + } + } + } + }; + + /** + * Take an item and add event handlers to make it become draggable. + * + * @param {HTMLElement} item + * The item to add event handlers to. + */ + Drupal.tableDrag.prototype.makeDraggable = function (item) { + var self = this; + var $item = $(item); + // Add a class to the title link. + $item.find('td:first-of-type').find('a').addClass('menu-item__link'); + // Create the handle. + var handle = $('
     
    ').attr('title', Drupal.t('Drag to re-order')); + // Insert the handle after indentations (if any). + var $indentationLast = $item.find('td:first-of-type').find('.js-indentation').eq(-1); + if ($indentationLast.length) { + $indentationLast.after(handle); + // Update the total width of indentation in this entire table. + self.indentCount = Math.max($item.find('.js-indentation').length, self.indentCount); + } + else { + $item.find('td').eq(0).prepend(handle); + } + + handle.on('mousedown touchstart pointerdown', function (event) { + event.preventDefault(); + if (event.originalEvent.type === 'touchstart') { + event = event.originalEvent.touches[0]; + } + self.dragStart(event, self, item); + }); + + // Prevent the anchor tag from jumping us to the top of the page. + handle.on('click', function (e) { + e.preventDefault(); + }); + + // Set blur cleanup when a handle is focused. + handle.on('focus', function () { + self.safeBlur = true; + }); + + // On blur, fire the same function as a touchend/mouseup. This is used to + // update values after a row has been moved through the keyboard support. + handle.on('blur', function (event) { + if (self.rowObject && self.safeBlur) { + self.dropRow(event, self); + } + }); + + // Add arrow-key support to the handle. + handle.on('keydown', function (event) { + // If a rowObject doesn't yet exist and this isn't the tab key. + if (event.keyCode !== 9 && !self.rowObject) { + self.rowObject = new self.row(item, 'keyboard', self.indentEnabled, self.maxDepth, true); + } + + var keyChange = false; + var groupHeight; + + /* eslint-disable no-fallthrough */ + + switch (event.keyCode) { + // Left arrow. + case 37: + // Safari left arrow. + case 63234: + keyChange = true; + self.rowObject.indent(-1 * self.rtl); + break; + + // Up arrow. + case 38: + // Safari up arrow. + case 63232: + var $previousRow = $(self.rowObject.element).prev('tr:first-of-type'); + var previousRow = $previousRow.get(0); + while (previousRow && $previousRow.is(':hidden')) { + $previousRow = $(previousRow).prev('tr:first-of-type'); + previousRow = $previousRow.get(0); + } + if (previousRow) { + // Do not allow the onBlur cleanup. + self.safeBlur = false; + self.rowObject.direction = 'up'; + keyChange = true; + + if ($(item).is('.tabledrag-root')) { + // Swap with the previous top-level row. + groupHeight = 0; + while (previousRow && $previousRow.find('.js-indentation').length) { + $previousRow = $(previousRow).prev('tr:first-of-type'); + previousRow = $previousRow.get(0); + groupHeight += $previousRow.is(':hidden') ? 0 : previousRow.offsetHeight; + } + if (previousRow) { + self.rowObject.swap('before', previousRow); + // No need to check for indentation, 0 is the only valid one. + window.scrollBy(0, -groupHeight); + } + } + else if (self.table.tBodies[0].rows[0] !== previousRow || $previousRow.is('.draggable')) { + // Swap with the previous row (unless previous row is the first + // one and undraggable). + self.rowObject.swap('before', previousRow); + self.rowObject.interval = null; + self.rowObject.indent(0); + window.scrollBy(0, -parseInt(item.offsetHeight, 10)); + } + // Regain focus after the DOM manipulation. + handle.trigger('focus'); + } + break; + + // Right arrow. + case 39: + // Safari right arrow. + case 63235: + keyChange = true; + self.rowObject.indent(self.rtl); + break; + + // Down arrow. + case 40: + // Safari down arrow. + case 63233: + var $nextRow = $(self.rowObject.group).eq(-1).next('tr:first-of-type'); + var nextRow = $nextRow.get(0); + while (nextRow && $nextRow.is(':hidden')) { + $nextRow = $(nextRow).next('tr:first-of-type'); + nextRow = $nextRow.get(0); + } + if (nextRow) { + // Do not allow the onBlur cleanup. + self.safeBlur = false; + self.rowObject.direction = 'down'; + keyChange = true; + + if ($(item).is('.tabledrag-root')) { + // Swap with the next group (necessarily a top-level one). + groupHeight = 0; + var nextGroup = new self.row(nextRow, 'keyboard', self.indentEnabled, self.maxDepth, false); + if (nextGroup) { + $(nextGroup.group).each(function () { + groupHeight += $(this).is(':hidden') ? 0 : this.offsetHeight; + }); + var nextGroupRow = $(nextGroup.group).eq(-1).get(0); + self.rowObject.swap('after', nextGroupRow); + // No need to check for indentation, 0 is the only valid one. + window.scrollBy(0, parseInt(groupHeight, 10)); + } + } + else { + // Swap with the next row. + self.rowObject.swap('after', nextRow); + self.rowObject.interval = null; + self.rowObject.indent(0); + window.scrollBy(0, parseInt(item.offsetHeight, 10)); + } + // Regain focus after the DOM manipulation. + handle.trigger('focus'); + } + break; + } + + /* eslint-enable no-fallthrough */ + + if (self.rowObject && self.rowObject.changed === true) { + $(item).addClass('drag'); + if (self.oldRowElement) { + $(self.oldRowElement).removeClass('drag-previous'); + } + self.oldRowElement = item; + if (self.striping === true) { + self.restripeTable(); + } + self.onDrag(); + } + + // Returning false if we have an arrow key to prevent scrolling. + if (keyChange) { + return false; + } + }); + + // Compatibility addition, return false on keypress to prevent unwanted + // scrolling. IE and Safari will suppress scrolling on keydown, but all + // other browsers need to return false on keypress. + // http://www.quirksmode.org/js/keys.html + handle.on('keypress', function (event) { + + /* eslint-disable no-fallthrough */ + + switch (event.keyCode) { + // Left arrow. + case 37: + // Up arrow. + case 38: + // Right arrow. + case 39: + // Down arrow. + case 40: + return false; + } + + /* eslint-enable no-fallthrough */ + + }); + }; + + /** + * Pointer event initiator, creates drag object and information. + * + * @param {jQuery.Event} event + * The event object that trigger the drag. + * @param {Drupal.tableDrag} self + * The drag handle. + * @param {HTMLElement} item + * The item that that is being dragged. + */ + Drupal.tableDrag.prototype.dragStart = function (event, self, item) { + // Create a new dragObject recording the pointer information. + self.dragObject = {}; + self.dragObject.initOffset = self.getPointerOffset(item, event); + self.dragObject.initPointerCoords = self.pointerCoords(event); + if (self.indentEnabled) { + self.dragObject.indentPointerPos = self.dragObject.initPointerCoords; + } + + // If there's a lingering row object from the keyboard, remove its focus. + if (self.rowObject) { + $(self.rowObject.element).find('a.tabledrag-handle').trigger('blur'); + } + + // Create a new rowObject for manipulation of this row. + self.rowObject = new self.row(item, 'pointer', self.indentEnabled, self.maxDepth, true); + + // Save the position of the table. + self.table.topY = $(self.table).offset().top; + self.table.bottomY = self.table.topY + self.table.offsetHeight; + + // Add classes to the handle and row. + $(item).addClass('drag'); + + // Set the document to use the move cursor during drag. + $('body').addClass('drag'); + if (self.oldRowElement) { + $(self.oldRowElement).removeClass('drag-previous'); + } + }; + + /** + * Pointer movement handler, bound to document. + * + * @param {jQuery.Event} event + * The pointer event. + * @param {Drupal.tableDrag} self + * The tableDrag instance. + * + * @return {bool|undefined} + * Undefined if no dragObject is defined, false otherwise. + */ + Drupal.tableDrag.prototype.dragRow = function (event, self) { + if (self.dragObject) { + self.currentPointerCoords = self.pointerCoords(event); + var y = self.currentPointerCoords.y - self.dragObject.initOffset.y; + var x = self.currentPointerCoords.x - self.dragObject.initOffset.x; + + // Check for row swapping and vertical scrolling. + if (y !== self.oldY) { + self.rowObject.direction = y > self.oldY ? 'down' : 'up'; + // Update the old value. + self.oldY = y; + // Check if the window should be scrolled (and how fast). + var scrollAmount = self.checkScroll(self.currentPointerCoords.y); + // Stop any current scrolling. + clearInterval(self.scrollInterval); + // Continue scrolling if the mouse has moved in the scroll direction. + if (scrollAmount > 0 && self.rowObject.direction === 'down' || scrollAmount < 0 && self.rowObject.direction === 'up') { + self.setScroll(scrollAmount); + } + + // If we have a valid target, perform the swap and restripe the table. + var currentRow = self.findDropTargetRow(x, y); + if (currentRow) { + if (self.rowObject.direction === 'down') { + self.rowObject.swap('after', currentRow, self); + } + else { + self.rowObject.swap('before', currentRow, self); + } + if (self.striping === true) { + self.restripeTable(); + } + } + } + + // Similar to row swapping, handle indentations. + if (self.indentEnabled) { + var xDiff = self.currentPointerCoords.x - self.dragObject.indentPointerPos.x; + // Set the number of indentations the pointer has been moved left or + // right. + var indentDiff = Math.round(xDiff / self.indentAmount); + // Indent the row with our estimated diff, which may be further + // restricted according to the rows around this row. + var indentChange = self.rowObject.indent(indentDiff); + // Update table and pointer indentations. + self.dragObject.indentPointerPos.x += self.indentAmount * indentChange * self.rtl; + self.indentCount = Math.max(self.indentCount, self.rowObject.indents); + } + + return false; + } + }; + + /** + * Pointerup behavior. + * + * @param {jQuery.Event} event + * The pointer event. + * @param {Drupal.tableDrag} self + * The tableDrag instance. + */ + Drupal.tableDrag.prototype.dropRow = function (event, self) { + var droppedRow; + var $droppedRow; + + // Drop row functionality. + if (self.rowObject !== null) { + droppedRow = self.rowObject.element; + $droppedRow = $(droppedRow); + // The row is already in the right place so we just release it. + if (self.rowObject.changed === true) { + // Update the fields in the dropped row. + self.updateFields(droppedRow); + + // If a setting exists for affecting the entire group, update all the + // fields in the entire dragged group. + for (var group in self.tableSettings) { + if (self.tableSettings.hasOwnProperty(group)) { + var rowSettings = self.rowSettings(group, droppedRow); + if (rowSettings.relationship === 'group') { + for (var n in self.rowObject.children) { + if (self.rowObject.children.hasOwnProperty(n)) { + self.updateField(self.rowObject.children[n], group); + } + } + } + } + } + + self.rowObject.markChanged(); + if (self.changed === false) { + $(Drupal.theme('tableDragChangedWarning')).insertBefore(self.table).hide().fadeIn('slow'); + self.changed = true; + } + } + + if (self.indentEnabled) { + self.rowObject.removeIndentClasses(); + } + if (self.oldRowElement) { + $(self.oldRowElement).removeClass('drag-previous'); + } + $droppedRow.removeClass('drag').addClass('drag-previous'); + self.oldRowElement = droppedRow; + self.onDrop(); + self.rowObject = null; + } + + // Functionality specific only to pointerup events. + if (self.dragObject !== null) { + self.dragObject = null; + $('body').removeClass('drag'); + clearInterval(self.scrollInterval); + } + }; + + /** + * Get the coordinates from the event (allowing for browser differences). + * + * @param {jQuery.Event} event + * The pointer event. + * + * @return {object} + * An object with `x` and `y` keys indicating the position. + */ + Drupal.tableDrag.prototype.pointerCoords = function (event) { + if (event.pageX || event.pageY) { + return {x: event.pageX, y: event.pageY}; + } + return { + x: event.clientX + document.body.scrollLeft - document.body.clientLeft, + y: event.clientY + document.body.scrollTop - document.body.clientTop + }; + }; + + /** + * Get the event offset from the target element. + * + * Given a target element and a pointer event, get the event offset from that + * element. To do this we need the element's position and the target position. + * + * @param {HTMLElement} target + * The target HTML element. + * @param {jQuery.Event} event + * The pointer event. + * + * @return {object} + * An object with `x` and `y` keys indicating the position. + */ + Drupal.tableDrag.prototype.getPointerOffset = function (target, event) { + var docPos = $(target).offset(); + var pointerPos = this.pointerCoords(event); + return {x: pointerPos.x - docPos.left, y: pointerPos.y - docPos.top}; + }; + + /** + * Find the row the mouse is currently over. + * + * This row is then taken and swapped with the one being dragged. + * + * @param {number} x + * The x coordinate of the mouse on the page (not the screen). + * @param {number} y + * The y coordinate of the mouse on the page (not the screen). + * + * @return {*} + * The drop target row, if found. + */ + Drupal.tableDrag.prototype.findDropTargetRow = function (x, y) { + var rows = $(this.table.tBodies[0].rows).not(':hidden'); + for (var n = 0; n < rows.length; n++) { + var row = rows[n]; + var $row = $(row); + var rowY = $row.offset().top; + var rowHeight; + // Because Safari does not report offsetHeight on table rows, but does on + // table cells, grab the firstChild of the row and use that instead. + // http://jacob.peargrove.com/blog/2006/technical/table-row-offsettop-bug-in-safari. + if (row.offsetHeight === 0) { + rowHeight = parseInt(row.firstChild.offsetHeight, 10) / 2; + } + // Other browsers. + else { + rowHeight = parseInt(row.offsetHeight, 10) / 2; + } + + // Because we always insert before, we need to offset the height a bit. + if ((y > (rowY - rowHeight)) && (y < (rowY + rowHeight))) { + if (this.indentEnabled) { + // Check that this row is not a child of the row being dragged. + for (n in this.rowObject.group) { + if (this.rowObject.group[n] === row) { + return null; + } + } + } + else { + // Do not allow a row to be swapped with itself. + if (row === this.rowObject.element) { + return null; + } + } + + // Check that swapping with this row is allowed. + if (!this.rowObject.isValidSwap(row)) { + return null; + } + + // We may have found the row the mouse just passed over, but it doesn't + // take into account hidden rows. Skip backwards until we find a + // draggable row. + while ($row.is(':hidden') && $row.prev('tr').is(':hidden')) { + $row = $row.prev('tr:first-of-type'); + row = $row.get(0); + } + return row; + } + } + return null; + }; + + /** + * After the row is dropped, update the table fields. + * + * @param {HTMLElement} changedRow + * DOM object for the row that was just dropped. + */ + Drupal.tableDrag.prototype.updateFields = function (changedRow) { + for (var group in this.tableSettings) { + if (this.tableSettings.hasOwnProperty(group)) { + // Each group may have a different setting for relationship, so we find + // the source rows for each separately. + this.updateField(changedRow, group); + } + } + }; + + /** + * After the row is dropped, update a single table field. + * + * @param {HTMLElement} changedRow + * DOM object for the row that was just dropped. + * @param {string} group + * The settings group on which field updates will occur. + */ + Drupal.tableDrag.prototype.updateField = function (changedRow, group) { + var rowSettings = this.rowSettings(group, changedRow); + var $changedRow = $(changedRow); + var sourceRow; + var $previousRow; + var previousRow; + var useSibling; + // Set the row as its own target. + if (rowSettings.relationship === 'self' || rowSettings.relationship === 'group') { + sourceRow = changedRow; + } + // Siblings are easy, check previous and next rows. + else if (rowSettings.relationship === 'sibling') { + $previousRow = $changedRow.prev('tr:first-of-type'); + previousRow = $previousRow.get(0); + var $nextRow = $changedRow.next('tr:first-of-type'); + var nextRow = $nextRow.get(0); + sourceRow = changedRow; + if ($previousRow.is('.draggable') && $previousRow.find('.' + group).length) { + if (this.indentEnabled) { + if ($previousRow.find('.js-indentations').length === $changedRow.find('.js-indentations').length) { + sourceRow = previousRow; + } + } + else { + sourceRow = previousRow; + } + } + else if ($nextRow.is('.draggable') && $nextRow.find('.' + group).length) { + if (this.indentEnabled) { + if ($nextRow.find('.js-indentations').length === $changedRow.find('.js-indentations').length) { + sourceRow = nextRow; + } + } + else { + sourceRow = nextRow; + } + } + } + // Parents, look up the tree until we find a field not in this group. + // Go up as many parents as indentations in the changed row. + else if (rowSettings.relationship === 'parent') { + $previousRow = $changedRow.prev('tr'); + previousRow = $previousRow; + while ($previousRow.length && $previousRow.find('.js-indentation').length >= this.rowObject.indents) { + $previousRow = $previousRow.prev('tr'); + previousRow = $previousRow; + } + // If we found a row. + if ($previousRow.length) { + sourceRow = $previousRow.get(0); + } + // Otherwise we went all the way to the left of the table without finding + // a parent, meaning this item has been placed at the root level. + else { + // Use the first row in the table as source, because it's guaranteed to + // be at the root level. Find the first item, then compare this row + // against it as a sibling. + sourceRow = $(this.table).find('tr.draggable:first-of-type').get(0); + if (sourceRow === this.rowObject.element) { + sourceRow = $(this.rowObject.group[this.rowObject.group.length - 1]).next('tr.draggable').get(0); + } + useSibling = true; + } + } + + // Because we may have moved the row from one category to another, + // take a look at our sibling and borrow its sources and targets. + this.copyDragClasses(sourceRow, changedRow, group); + rowSettings = this.rowSettings(group, changedRow); + + // In the case that we're looking for a parent, but the row is at the top + // of the tree, copy our sibling's values. + if (useSibling) { + rowSettings.relationship = 'sibling'; + rowSettings.source = rowSettings.target; + } + + var targetClass = '.' + rowSettings.target; + var targetElement = $changedRow.find(targetClass).get(0); + + // Check if a target element exists in this row. + if (targetElement) { + var sourceClass = '.' + rowSettings.source; + var sourceElement = $(sourceClass, sourceRow).get(0); + switch (rowSettings.action) { + case 'depth': + // Get the depth of the target row. + targetElement.value = $(sourceElement).closest('tr').find('.js-indentation').length; + break; + + case 'match': + // Update the value. + targetElement.value = sourceElement.value; + break; + + case 'order': + var siblings = this.rowObject.findSiblings(rowSettings); + if ($(targetElement).is('select')) { + // Get a list of acceptable values. + var values = []; + $(targetElement).find('option').each(function () { + values.push(this.value); + }); + var maxVal = values[values.length - 1]; + // Populate the values in the siblings. + $(siblings).find(targetClass).each(function () { + // If there are more items than possible values, assign the + // maximum value to the row. + if (values.length > 0) { + this.value = values.shift(); + } + else { + this.value = maxVal; + } + }); + } + else { + // Assume a numeric input field. + var weight = parseInt($(siblings[0]).find(targetClass).val(), 10) || 0; + $(siblings).find(targetClass).each(function () { + this.value = weight; + weight++; + }); + } + break; + } + } + }; + + /** + * Copy all tableDrag related classes from one row to another. + * + * Copy all special tableDrag classes from one row's form elements to a + * different one, removing any special classes that the destination row + * may have had. + * + * @param {HTMLElement} sourceRow + * The element for the source row. + * @param {HTMLElement} targetRow + * The element for the target row. + * @param {string} group + * The group selector. + */ + Drupal.tableDrag.prototype.copyDragClasses = function (sourceRow, targetRow, group) { + var sourceElement = $(sourceRow).find('.' + group); + var targetElement = $(targetRow).find('.' + group); + if (sourceElement.length && targetElement.length) { + targetElement[0].className = sourceElement[0].className; + } + }; + + /** + * Check the suggested scroll of the table. + * + * @param {number} cursorY + * The Y position of the cursor. + * + * @return {number} + * The suggested scroll. + */ + Drupal.tableDrag.prototype.checkScroll = function (cursorY) { + var de = document.documentElement; + var b = document.body; + + var windowHeight = this.windowHeight = window.innerHeight || (de.clientHeight && de.clientWidth !== 0 ? de.clientHeight : b.offsetHeight); + var scrollY; + if (document.all) { + scrollY = this.scrollY = !de.scrollTop ? b.scrollTop : de.scrollTop; + } + else { + scrollY = this.scrollY = window.pageYOffset ? window.pageYOffset : window.scrollY; + } + var trigger = this.scrollSettings.trigger; + var delta = 0; + + // Return a scroll speed relative to the edge of the screen. + if (cursorY - scrollY > windowHeight - trigger) { + delta = trigger / (windowHeight + scrollY - cursorY); + delta = (delta > 0 && delta < trigger) ? delta : trigger; + return delta * this.scrollSettings.amount; + } + else if (cursorY - scrollY < trigger) { + delta = trigger / (cursorY - scrollY); + delta = (delta > 0 && delta < trigger) ? delta : trigger; + return -delta * this.scrollSettings.amount; + } + }; + + /** + * Set the scroll for the table. + * + * @param {number} scrollAmount + * The amount of scroll to apply to the window. + */ + Drupal.tableDrag.prototype.setScroll = function (scrollAmount) { + var self = this; + + this.scrollInterval = setInterval(function () { + // Update the scroll values stored in the object. + self.checkScroll(self.currentPointerCoords.y); + var aboveTable = self.scrollY > self.table.topY; + var belowTable = self.scrollY + self.windowHeight < self.table.bottomY; + if (scrollAmount > 0 && belowTable || scrollAmount < 0 && aboveTable) { + window.scrollBy(0, scrollAmount); + } + }, this.scrollSettings.interval); + }; + + /** + * Command to restripe table properly. + */ + Drupal.tableDrag.prototype.restripeTable = function () { + // :even and :odd are reversed because jQuery counts from 0 and + // we count from 1, so we're out of sync. + // Match immediate children of the parent element to allow nesting. + $(this.table).find('> tbody > tr.draggable, > tr.draggable') + .filter(':visible') + .filter(':odd').removeClass('odd').addClass('even').end() + .filter(':even').removeClass('even').addClass('odd'); + }; + + /** + * Stub function. Allows a custom handler when a row begins dragging. + * + * @return {null} + * Returns null when the stub function is used. + */ + Drupal.tableDrag.prototype.onDrag = function () { + return null; + }; + + /** + * Stub function. Allows a custom handler when a row is dropped. + * + * @return {null} + * Returns null when the stub function is used. + */ + Drupal.tableDrag.prototype.onDrop = function () { + return null; + }; + + /** + * Constructor to make a new object to manipulate a table row. + * + * @param {HTMLElement} tableRow + * The DOM element for the table row we will be manipulating. + * @param {string} method + * The method in which this row is being moved. Either 'keyboard' or + * 'mouse'. + * @param {bool} indentEnabled + * Whether the containing table uses indentations. Used for optimizations. + * @param {number} maxDepth + * The maximum amount of indentations this row may contain. + * @param {bool} addClasses + * Whether we want to add classes to this row to indicate child + * relationships. + */ + Drupal.tableDrag.prototype.row = function (tableRow, method, indentEnabled, maxDepth, addClasses) { + var $tableRow = $(tableRow); + + this.element = tableRow; + this.method = method; + this.group = [tableRow]; + this.groupDepth = $tableRow.find('.js-indentation').length; + this.changed = false; + this.table = $tableRow.closest('table')[0]; + this.indentEnabled = indentEnabled; + this.maxDepth = maxDepth; + // Direction the row is being moved. + this.direction = ''; + if (this.indentEnabled) { + this.indents = $tableRow.find('.js-indentation').length; + this.children = this.findChildren(addClasses); + this.group = $.merge(this.group, this.children); + // Find the depth of this entire group. + for (var n = 0; n < this.group.length; n++) { + this.groupDepth = Math.max($(this.group[n]).find('.js-indentation').length, this.groupDepth); + } + } + }; + + /** + * Find all children of rowObject by indentation. + * + * @param {bool} addClasses + * Whether we want to add classes to this row to indicate child + * relationships. + * + * @return {Array} + * An array of children of the row. + */ + Drupal.tableDrag.prototype.row.prototype.findChildren = function (addClasses) { + var parentIndentation = this.indents; + var currentRow = $(this.element, this.table).next('tr.draggable'); + var rows = []; + var child = 0; + + function rowIndentation(indentNum, el) { + var self = $(el); + if (child === 1 && (indentNum === parentIndentation)) { + self.addClass('tree-child-first'); + } + if (indentNum === parentIndentation) { + self.addClass('tree-child'); + } + else if (indentNum > parentIndentation) { + self.addClass('tree-child-horizontal'); + } + } + + while (currentRow.length) { + // A greater indentation indicates this is a child. + if (currentRow.find('.js-indentation').length > parentIndentation) { + child++; + rows.push(currentRow[0]); + if (addClasses) { + currentRow.find('.js-indentation').each(rowIndentation); + } + } + else { + break; + } + currentRow = currentRow.next('tr.draggable'); + } + if (addClasses && rows.length) { + $(rows[rows.length - 1]).find('.js-indentation:nth-child(' + (parentIndentation + 1) + ')').addClass('tree-child-last'); + } + return rows; + }; + + /** + * Ensure that two rows are allowed to be swapped. + * + * @param {HTMLElement} row + * DOM object for the row being considered for swapping. + * + * @return {bool} + * Whether the swap is a valid swap or not. + */ + Drupal.tableDrag.prototype.row.prototype.isValidSwap = function (row) { + var $row = $(row); + if (this.indentEnabled) { + var prevRow; + var nextRow; + if (this.direction === 'down') { + prevRow = row; + nextRow = $row.next('tr').get(0); + } + else { + prevRow = $row.prev('tr').get(0); + nextRow = row; + } + this.interval = this.validIndentInterval(prevRow, nextRow); + + // We have an invalid swap if the valid indentations interval is empty. + if (this.interval.min > this.interval.max) { + return false; + } + } + + // Do not let an un-draggable first row have anything put before it. + if (this.table.tBodies[0].rows[0] === row && $row.is(':not(.draggable)')) { + return false; + } + + return true; + }; + + /** + * Perform the swap between two rows. + * + * @param {string} position + * Whether the swap will occur 'before' or 'after' the given row. + * @param {HTMLElement} row + * DOM element what will be swapped with the row group. + */ + Drupal.tableDrag.prototype.row.prototype.swap = function (position, row) { + // Makes sure only DOM object are passed to Drupal.detachBehaviors(). + this.group.forEach(function (row) { + Drupal.detachBehaviors(row, drupalSettings, 'move'); + }); + $(row)[position](this.group); + // Makes sure only DOM object are passed to Drupal.attachBehaviors()s. + this.group.forEach(function (row) { + Drupal.attachBehaviors(row, drupalSettings); + }); + this.changed = true; + this.onSwap(row); + }; + + /** + * Determine the valid indentations interval for the row at a given position. + * + * @param {?HTMLElement} prevRow + * DOM object for the row before the tested position + * (or null for first position in the table). + * @param {?HTMLElement} nextRow + * DOM object for the row after the tested position + * (or null for last position in the table). + * + * @return {object} + * An object with the keys `min` and `max` to indicate the valid indent + * interval. + */ + Drupal.tableDrag.prototype.row.prototype.validIndentInterval = function (prevRow, nextRow) { + var $prevRow = $(prevRow); + var minIndent; + var maxIndent; + + // Minimum indentation: + // Do not orphan the next row. + minIndent = nextRow ? $(nextRow).find('.js-indentation').length : 0; + + // Maximum indentation: + if (!prevRow || $prevRow.is(':not(.draggable)') || $(this.element).is('.tabledrag-root')) { + // Do not indent: + // - the first row in the table, + // - rows dragged below a non-draggable row, + // - 'root' rows. + maxIndent = 0; + } + else { + // Do not go deeper than as a child of the previous row. + maxIndent = $prevRow.find('.js-indentation').length + ($prevRow.is('.tabledrag-leaf') ? 0 : 1); + // Limit by the maximum allowed depth for the table. + if (this.maxDepth) { + maxIndent = Math.min(maxIndent, this.maxDepth - (this.groupDepth - this.indents)); + } + } + + return {min: minIndent, max: maxIndent}; + }; + + /** + * Indent a row within the legal bounds of the table. + * + * @param {number} indentDiff + * The number of additional indentations proposed for the row (can be + * positive or negative). This number will be adjusted to nearest valid + * indentation level for the row. + * + * @return {number} + * The number of indentations applied. + */ + Drupal.tableDrag.prototype.row.prototype.indent = function (indentDiff) { + var $group = $(this.group); + // Determine the valid indentations interval if not available yet. + if (!this.interval) { + var prevRow = $(this.element).prev('tr').get(0); + var nextRow = $group.eq(-1).next('tr').get(0); + this.interval = this.validIndentInterval(prevRow, nextRow); + } + + // Adjust to the nearest valid indentation. + var indent = this.indents + indentDiff; + indent = Math.max(indent, this.interval.min); + indent = Math.min(indent, this.interval.max); + indentDiff = indent - this.indents; + + for (var n = 1; n <= Math.abs(indentDiff); n++) { + // Add or remove indentations. + if (indentDiff < 0) { + $group.find('.js-indentation:first-of-type').remove(); + this.indents--; + } + else { + $group.find('td:first-of-type').prepend(Drupal.theme('tableDragIndentation')); + this.indents++; + } + } + if (indentDiff) { + // Update indentation for this row. + this.changed = true; + this.groupDepth += indentDiff; + this.onIndent(); + } + + return indentDiff; + }; + + /** + * Find all siblings for a row. + * + * According to its subgroup or indentation. Note that the passed-in row is + * included in the list of siblings. + * + * @param {object} rowSettings + * The field settings we're using to identify what constitutes a sibling. + * + * @return {Array} + * An array of siblings. + */ + Drupal.tableDrag.prototype.row.prototype.findSiblings = function (rowSettings) { + var siblings = []; + var directions = ['prev', 'next']; + var rowIndentation = this.indents; + var checkRowIndentation; + for (var d = 0; d < directions.length; d++) { + var checkRow = $(this.element)[directions[d]](); + while (checkRow.length) { + // Check that the sibling contains a similar target field. + if (checkRow.find('.' + rowSettings.target)) { + // Either add immediately if this is a flat table, or check to ensure + // that this row has the same level of indentation. + if (this.indentEnabled) { + checkRowIndentation = checkRow.find('.js-indentation').length; + } + + if (!(this.indentEnabled) || (checkRowIndentation === rowIndentation)) { + siblings.push(checkRow[0]); + } + else if (checkRowIndentation < rowIndentation) { + // No need to keep looking for siblings when we get to a parent. + break; + } + } + else { + break; + } + checkRow = checkRow[directions[d]](); + } + // Since siblings are added in reverse order for previous, reverse the + // completed list of previous siblings. Add the current row and continue. + if (directions[d] === 'prev') { + siblings.reverse(); + siblings.push(this.element); + } + } + return siblings; + }; + + /** + * Remove indentation helper classes from the current row group. + */ + Drupal.tableDrag.prototype.row.prototype.removeIndentClasses = function () { + for (var n in this.children) { + if (this.children.hasOwnProperty(n)) { + $(this.children[n]).find('.js-indentation') + .removeClass('tree-child') + .removeClass('tree-child-first') + .removeClass('tree-child-last') + .removeClass('tree-child-horizontal'); + } + } + }; + + /** + * Add an asterisk or other marker to the changed row. + */ + Drupal.tableDrag.prototype.row.prototype.markChanged = function () { + var marker = Drupal.theme('tableDragChangedMarker'); + var cell = $(this.element).find('td:first-of-type'); + if (cell.find('abbr.tabledrag-changed').length === 0) { + cell.append(marker); + } + }; + + /** + * Stub function. Allows a custom handler when a row is indented. + * + * @return {null} + * Returns null when the stub function is used. + */ + Drupal.tableDrag.prototype.row.prototype.onIndent = function () { + return null; + }; + + /** + * Stub function. Allows a custom handler when a row is swapped. + * + * @param {HTMLElement} swappedRow + * The element for the swapped row. + * + * @return {null} + * Returns null when the stub function is used. + */ + Drupal.tableDrag.prototype.row.prototype.onSwap = function (swappedRow) { + return null; + }; + + $.extend(Drupal.theme, /** @lends Drupal.theme */{ + + /** + * @return {string} + * Markup for the marker. + */ + tableDragChangedMarker: function () { + return '*'; + }, + + /** + * @return {string} + * Markup for the indentation. + */ + tableDragIndentation: function () { + return '
     
    '; + }, + + /** + * @return {string} + * Markup for the warning. + */ + tableDragChangedWarning: function () { + return ''; + } + }); + +})(jQuery, Drupal, drupalSettings); diff --git a/core/misc/tableheader.es6.js b/core/misc/tableheader.es6.js new file mode 100644 index 0000000000..8fc1b04896 --- /dev/null +++ b/core/misc/tableheader.es6.js @@ -0,0 +1,316 @@ +/** + * @file + * Sticky table headers. + */ + +(function ($, Drupal, displace) { + + 'use strict'; + + /** + * Attaches sticky table headers. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the sticky table header behavior. + */ + Drupal.behaviors.tableHeader = { + attach: function (context) { + $(window).one('scroll.TableHeaderInit', {context: context}, tableHeaderInitHandler); + } + }; + + function scrollValue(position) { + return document.documentElement[position] || document.body[position]; + } + + // Select and initialize sticky table headers. + function tableHeaderInitHandler(e) { + var $tables = $(e.data.context).find('table.sticky-enabled').once('tableheader'); + var il = $tables.length; + for (var i = 0; i < il; i++) { + TableHeader.tables.push(new TableHeader($tables[i])); + } + forTables('onScroll'); + } + + // Helper method to loop through tables and execute a method. + function forTables(method, arg) { + var tables = TableHeader.tables; + var il = tables.length; + for (var i = 0; i < il; i++) { + tables[i][method](arg); + } + } + + function tableHeaderResizeHandler(e) { + forTables('recalculateSticky'); + } + + function tableHeaderOnScrollHandler(e) { + forTables('onScroll'); + } + + function tableHeaderOffsetChangeHandler(e, offsets) { + forTables('stickyPosition', offsets.top); + } + + // Bind event that need to change all tables. + $(window).on({ + + /** + * When resizing table width can change, recalculate everything. + * + * @ignore + */ + 'resize.TableHeader': tableHeaderResizeHandler, + + /** + * Bind only one event to take care of calling all scroll callbacks. + * + * @ignore + */ + 'scroll.TableHeader': tableHeaderOnScrollHandler + }); + // Bind to custom Drupal events. + $(document).on({ + + /** + * Recalculate columns width when window is resized and when show/hide + * weight is triggered. + * + * @ignore + */ + 'columnschange.TableHeader': tableHeaderResizeHandler, + + /** + * Recalculate TableHeader.topOffset when viewport is resized. + * + * @ignore + */ + 'drupalViewportOffsetChange.TableHeader': tableHeaderOffsetChangeHandler + }); + + /** + * Constructor for the tableHeader object. Provides sticky table headers. + * + * TableHeader will make the current table header stick to the top of the page + * if the table is very long. + * + * @constructor Drupal.TableHeader + * + * @param {HTMLElement} table + * DOM object for the table to add a sticky header to. + * + * @listens event:columnschange + */ + function TableHeader(table) { + var $table = $(table); + + /** + * @name Drupal.TableHeader#$originalTable + * + * @type {HTMLElement} + */ + this.$originalTable = $table; + + /** + * @type {jQuery} + */ + this.$originalHeader = $table.children('thead'); + + /** + * @type {jQuery} + */ + this.$originalHeaderCells = this.$originalHeader.find('> tr > th'); + + /** + * @type {null|bool} + */ + this.displayWeight = null; + this.$originalTable.addClass('sticky-table'); + this.tableHeight = $table[0].clientHeight; + this.tableOffset = this.$originalTable.offset(); + + // React to columns change to avoid making checks in the scroll callback. + this.$originalTable.on('columnschange', {tableHeader: this}, function (e, display) { + var tableHeader = e.data.tableHeader; + if (tableHeader.displayWeight === null || tableHeader.displayWeight !== display) { + tableHeader.recalculateSticky(); + } + tableHeader.displayWeight = display; + }); + + // Create and display sticky header. + this.createSticky(); + } + + /** + * Store the state of TableHeader. + */ + $.extend(TableHeader, /** @lends Drupal.TableHeader */{ + + /** + * This will store the state of all processed tables. + * + * @type {Array.} + */ + tables: [] + }); + + /** + * Extend TableHeader prototype. + */ + $.extend(TableHeader.prototype, /** @lends Drupal.TableHeader# */{ + + /** + * Minimum height in pixels for the table to have a sticky header. + * + * @type {number} + */ + minHeight: 100, + + /** + * Absolute position of the table on the page. + * + * @type {?Drupal~displaceOffset} + */ + tableOffset: null, + + /** + * Absolute position of the table on the page. + * + * @type {?number} + */ + tableHeight: null, + + /** + * Boolean storing the sticky header visibility state. + * + * @type {bool} + */ + stickyVisible: false, + + /** + * Create the duplicate header. + */ + createSticky: function () { + // Clone the table header so it inherits original jQuery properties. + var $stickyHeader = this.$originalHeader.clone(true); + // Hide the table to avoid a flash of the header clone upon page load. + this.$stickyTable = $('') + .css({ + visibility: 'hidden', + position: 'fixed', + top: '0px' + }) + .append($stickyHeader) + .insertBefore(this.$originalTable); + + this.$stickyHeaderCells = $stickyHeader.find('> tr > th'); + + // Initialize all computations. + this.recalculateSticky(); + }, + + /** + * Set absolute position of sticky. + * + * @param {number} offsetTop + * The top offset for the sticky header. + * @param {number} offsetLeft + * The left offset for the sticky header. + * + * @return {jQuery} + * The sticky table as a jQuery collection. + */ + stickyPosition: function (offsetTop, offsetLeft) { + var css = {}; + if (typeof offsetTop === 'number') { + css.top = offsetTop + 'px'; + } + if (typeof offsetLeft === 'number') { + css.left = (this.tableOffset.left - offsetLeft) + 'px'; + } + return this.$stickyTable.css(css); + }, + + /** + * Returns true if sticky is currently visible. + * + * @return {bool} + * The visibility status. + */ + checkStickyVisible: function () { + var scrollTop = scrollValue('scrollTop'); + var tableTop = this.tableOffset.top - displace.offsets.top; + var tableBottom = tableTop + this.tableHeight; + var visible = false; + + if (tableTop < scrollTop && scrollTop < (tableBottom - this.minHeight)) { + visible = true; + } + + this.stickyVisible = visible; + return visible; + }, + + /** + * Check if sticky header should be displayed. + * + * This function is throttled to once every 250ms to avoid unnecessary + * calls. + * + * @param {jQuery.Event} e + * The scroll event. + */ + onScroll: function (e) { + this.checkStickyVisible(); + // Track horizontal positioning relative to the viewport. + this.stickyPosition(null, scrollValue('scrollLeft')); + this.$stickyTable.css('visibility', this.stickyVisible ? 'visible' : 'hidden'); + }, + + /** + * Event handler: recalculates position of the sticky table header. + * + * @param {jQuery.Event} event + * Event being triggered. + */ + recalculateSticky: function (event) { + // Update table size. + this.tableHeight = this.$originalTable[0].clientHeight; + + // Update offset top. + displace.offsets.top = displace.calculateOffset('top'); + this.tableOffset = this.$originalTable.offset(); + this.stickyPosition(displace.offsets.top, scrollValue('scrollLeft')); + + // Update columns width. + var $that = null; + var $stickyCell = null; + var display = null; + // Resize header and its cell widths. + // Only apply width to visible table cells. This prevents the header from + // displaying incorrectly when the sticky header is no longer visible. + var il = this.$originalHeaderCells.length; + for (var i = 0; i < il; i++) { + $that = $(this.$originalHeaderCells[i]); + $stickyCell = this.$stickyHeaderCells.eq($that.index()); + display = $that.css('display'); + if (display !== 'none') { + $stickyCell.css({width: $that.css('width'), display: display}); + } + else { + $stickyCell.css('display', 'none'); + } + } + this.$stickyTable.css('width', this.$originalTable.outerWidth()); + } + }); + + // Expose constructor in the public space. + Drupal.TableHeader = TableHeader; + +}(jQuery, Drupal, window.parent.Drupal.displace)); diff --git a/core/misc/tableresponsive.es6.js b/core/misc/tableresponsive.es6.js new file mode 100644 index 0000000000..0127ec8873 --- /dev/null +++ b/core/misc/tableresponsive.es6.js @@ -0,0 +1,174 @@ +/** + * @file + * Responsive table functionality. + */ + +(function ($, Drupal, window) { + + 'use strict'; + + /** + * Attach the tableResponsive function to {@link Drupal.behaviors}. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches tableResponsive functionality. + */ + Drupal.behaviors.tableResponsive = { + attach: function (context, settings) { + var $tables = $(context).find('table.responsive-enabled').once('tableresponsive'); + if ($tables.length) { + var il = $tables.length; + for (var i = 0; i < il; i++) { + TableResponsive.tables.push(new TableResponsive($tables[i])); + } + } + } + }; + + /** + * The TableResponsive object optimizes table presentation for screen size. + * + * A responsive table hides columns at small screen sizes, leaving the most + * important columns visible to the end user. Users should not be prevented + * from accessing all columns, however. This class adds a toggle to a table + * with hidden columns that exposes the columns. Exposing the columns will + * likely break layouts, but it provides the user with a means to access + * data, which is a guiding principle of responsive design. + * + * @constructor Drupal.TableResponsive + * + * @param {HTMLElement} table + * The table element to initialize the responsive table on. + */ + function TableResponsive(table) { + this.table = table; + this.$table = $(table); + this.showText = Drupal.t('Show all columns'); + this.hideText = Drupal.t('Hide lower priority columns'); + // Store a reference to the header elements of the table so that the DOM is + // traversed only once to find them. + this.$headers = this.$table.find('th'); + // Add a link before the table for users to show or hide weight columns. + this.$link = $('') + .attr('title', Drupal.t('Show table cells that were hidden to make the table fit within a small screen.')) + .on('click', $.proxy(this, 'eventhandlerToggleColumns')); + + this.$table.before($('
    ').append(this.$link)); + + // Attach a resize handler to the window. + $(window) + .on('resize.tableresponsive', $.proxy(this, 'eventhandlerEvaluateColumnVisibility')) + .trigger('resize.tableresponsive'); + } + + /** + * Extend the TableResponsive function with a list of managed tables. + */ + $.extend(TableResponsive, /** @lends Drupal.TableResponsive */{ + + /** + * Store all created instances. + * + * @type {Array.} + */ + tables: [] + }); + + /** + * Associates an action link with the table that will show hidden columns. + * + * Columns are assumed to be hidden if their header has the class priority-low + * or priority-medium. + */ + $.extend(TableResponsive.prototype, /** @lends Drupal.TableResponsive# */{ + + /** + * @param {jQuery.Event} e + * The event triggered. + */ + eventhandlerEvaluateColumnVisibility: function (e) { + var pegged = parseInt(this.$link.data('pegged'), 10); + var hiddenLength = this.$headers.filter('.priority-medium:hidden, .priority-low:hidden').length; + // If the table has hidden columns, associate an action link with the + // table to show the columns. + if (hiddenLength > 0) { + this.$link.show().text(this.showText); + } + // When the toggle is pegged, its presence is maintained because the user + // has interacted with it. This is necessary to keep the link visible if + // the user adjusts screen size and changes the visibility of columns. + if (!pegged && hiddenLength === 0) { + this.$link.hide().text(this.hideText); + } + }, + + /** + * Toggle the visibility of columns based on their priority. + * + * Columns are classed with either 'priority-low' or 'priority-medium'. + * + * @param {jQuery.Event} e + * The event triggered. + */ + eventhandlerToggleColumns: function (e) { + e.preventDefault(); + var self = this; + var $hiddenHeaders = this.$headers.filter('.priority-medium:hidden, .priority-low:hidden'); + this.$revealedCells = this.$revealedCells || $(); + // Reveal hidden columns. + if ($hiddenHeaders.length > 0) { + $hiddenHeaders.each(function (index, element) { + var $header = $(this); + var position = $header.prevAll('th').length; + self.$table.find('tbody tr').each(function () { + var $cells = $(this).find('td').eq(position); + $cells.show(); + // Keep track of the revealed cells, so they can be hidden later. + self.$revealedCells = $().add(self.$revealedCells).add($cells); + }); + $header.show(); + // Keep track of the revealed headers, so they can be hidden later. + self.$revealedCells = $().add(self.$revealedCells).add($header); + }); + this.$link.text(this.hideText).data('pegged', 1); + } + // Hide revealed columns. + else { + this.$revealedCells.hide(); + // Strip the 'display:none' declaration from the style attributes of + // the table cells that .hide() added. + this.$revealedCells.each(function (index, element) { + var $cell = $(this); + var properties = $cell.attr('style').split(';'); + var newProps = []; + // The hide method adds display none to the element. The element + // should be returned to the same state it was in before the columns + // were revealed, so it is necessary to remove the display none value + // from the style attribute. + var match = /^display\s*\:\s*none$/; + for (var i = 0; i < properties.length; i++) { + var prop = properties[i]; + prop.trim(); + // Find the display:none property and remove it. + var isDisplayNone = match.exec(prop); + if (isDisplayNone) { + continue; + } + newProps.push(prop); + } + // Return the rest of the style attribute values to the element. + $cell.attr('style', newProps.join(';')); + }); + this.$link.text(this.showText).data('pegged', 0); + // Refresh the toggle link. + $(window).trigger('resize.tableresponsive'); + } + } + }); + + // Make the TableResponsive object available in the Drupal namespace. + Drupal.TableResponsive = TableResponsive; + +})(jQuery, Drupal, window); diff --git a/core/misc/tableselect.es6.js b/core/misc/tableselect.es6.js new file mode 100644 index 0000000000..243e000110 --- /dev/null +++ b/core/misc/tableselect.es6.js @@ -0,0 +1,159 @@ +/** + * @file + * Table select functionality. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Initialize tableSelects. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches tableSelect functionality. + */ + Drupal.behaviors.tableSelect = { + attach: function (context, settings) { + // Select the inner-most table in case of nested tables. + $(context).find('th.select-all').closest('table').once('table-select').each(Drupal.tableSelect); + } + }; + + /** + * Callback used in {@link Drupal.behaviors.tableSelect}. + */ + Drupal.tableSelect = function () { + // Do not add a "Select all" checkbox if there are no rows with checkboxes + // in the table. + if ($(this).find('td input[type="checkbox"]').length === 0) { + return; + } + + // Keep track of the table, which checkbox is checked and alias the + // settings. + var table = this; + var checkboxes; + var lastChecked; + var $table = $(table); + var strings = { + selectAll: Drupal.t('Select all rows in this table'), + selectNone: Drupal.t('Deselect all rows in this table') + }; + var updateSelectAll = function (state) { + // Update table's select-all checkbox (and sticky header's if available). + $table.prev('table.sticky-header').addBack().find('th.select-all input[type="checkbox"]').each(function () { + var $checkbox = $(this); + var stateChanged = $checkbox.prop('checked') !== state; + + $checkbox.attr('title', state ? strings.selectNone : strings.selectAll); + + /** + * @checkbox {HTMLElement} + */ + if (stateChanged) { + $checkbox.prop('checked', state).trigger('change'); + } + }); + }; + + // Find all to do our range searching. + Drupal.tableSelectRange($(e.target).closest('tr')[0], $(lastChecked).closest('tr')[0], e.target.checked); + } + + // If all checkboxes are checked, make sure the select-all one is checked + // too, otherwise keep unchecked. + updateSelectAll((checkboxes.length === checkboxes.filter(':checked').length)); + + // Keep track of the last checked checkbox. + lastChecked = e.target; + }); + + // If all checkboxes are checked on page load, make sure the select-all one + // is checked too, otherwise keep unchecked. + updateSelectAll((checkboxes.length === checkboxes.filter(':checked').length)); + }; + + /** + * @param {HTMLElement} from + * The HTML element representing the "from" part of the range. + * @param {HTMLElement} to + * The HTML element representing the "to" part of the range. + * @param {bool} state + * The state to set on the range. + */ + Drupal.tableSelectRange = function (from, to, state) { + // We determine the looping mode based on the order of from and to. + var mode = from.rowIndex > to.rowIndex ? 'previousSibling' : 'nextSibling'; + + // Traverse through the sibling nodes. + for (var i = from[mode]; i; i = i[mode]) { + var $i; + // Make sure that we're only dealing with elements. + if (i.nodeType !== 1) { + continue; + } + $i = $(i); + // Either add or remove the selected class based on the state of the + // target checkbox. + $i.toggleClass('selected', state); + $i.find('input[type="checkbox"]').prop('checked', state); + + if (to.nodeType) { + // If we are at the end of the range, stop. + if (i === to) { + break; + } + } + // A faster alternative to doing $(i).filter(to).length. + else if ($.filter(to, [i]).r.length) { + break; + } + } + }; + +})(jQuery, Drupal); diff --git a/core/misc/timezone.es6.js b/core/misc/timezone.es6.js new file mode 100644 index 0000000000..3c88d463dd --- /dev/null +++ b/core/misc/timezone.es6.js @@ -0,0 +1,76 @@ +/** + * @file + * Timezone detection. + */ + +(function ($, Drupal) { + + 'use strict'; + + /** + * Set the client's system time zone as default values of form fields. + * + * @type {Drupal~behavior} + */ + Drupal.behaviors.setTimezone = { + attach: function (context, settings) { + var $timezone = $(context).find('.timezone-detect').once('timezone'); + if ($timezone.length) { + var dateString = Date(); + // In some client environments, date strings include a time zone + // abbreviation, between 3 and 5 letters enclosed in parentheses, + // which can be interpreted by PHP. + var matches = dateString.match(/\(([A-Z]{3,5})\)/); + var abbreviation = matches ? matches[1] : 0; + + // For all other client environments, the abbreviation is set to "0" + // and the current offset from UTC and daylight saving time status are + // used to guess the time zone. + var dateNow = new Date(); + var offsetNow = dateNow.getTimezoneOffset() * -60; + + // Use January 1 and July 1 as test dates for determining daylight + // saving time status by comparing their offsets. + var dateJan = new Date(dateNow.getFullYear(), 0, 1, 12, 0, 0, 0); + var dateJul = new Date(dateNow.getFullYear(), 6, 1, 12, 0, 0, 0); + var offsetJan = dateJan.getTimezoneOffset() * -60; + var offsetJul = dateJul.getTimezoneOffset() * -60; + + var isDaylightSavingTime; + // If the offset from UTC is identical on January 1 and July 1, + // assume daylight saving time is not used in this time zone. + if (offsetJan === offsetJul) { + isDaylightSavingTime = ''; + } + // If the maximum annual offset is equivalent to the current offset, + // assume daylight saving time is in effect. + else if (Math.max(offsetJan, offsetJul) === offsetNow) { + isDaylightSavingTime = 1; + } + // Otherwise, assume daylight saving time is not in effect. + else { + isDaylightSavingTime = 0; + } + + // Submit request to the system/timezone callback and set the form + // field to the response time zone. The client date is passed to the + // callback for debugging purposes. Submit a synchronous request to + // avoid database errors associated with concurrent requests + // during install. + var path = 'system/timezone/' + abbreviation + '/' + offsetNow + '/' + isDaylightSavingTime; + $.ajax({ + async: false, + url: Drupal.url(path), + data: {date: dateString}, + dataType: 'json', + success: function (data) { + if (data) { + $timezone.val(data); + } + } + }); + } + } + }; + +})(jQuery, Drupal); diff --git a/core/misc/vertical-tabs.es6.js b/core/misc/vertical-tabs.es6.js new file mode 100644 index 0000000000..c7ad2fd2b4 --- /dev/null +++ b/core/misc/vertical-tabs.es6.js @@ -0,0 +1,252 @@ +/** + * @file + * Define vertical tabs functionality. + */ + +/** + * Triggers when form values inside a vertical tab changes. + * + * This is used to update the summary in vertical tabs in order to know what + * are the important fields' values. + * + * @event summaryUpdated + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * This script transforms a set of details into a stack of vertical tabs. + * + * Each tab may have a summary which can be updated by another + * script. For that to work, each details element has an associated + * 'verticalTabCallback' (with jQuery.data() attached to the details), + * which is called every time the user performs an update to a form + * element inside the tab pane. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches behaviors for vertical tabs. + */ + Drupal.behaviors.verticalTabs = { + attach: function (context) { + var width = drupalSettings.widthBreakpoint || 640; + var mq = '(max-width: ' + width + 'px)'; + + if (window.matchMedia(mq).matches) { + return; + } + + $(context).find('[data-vertical-tabs-panes]').once('vertical-tabs').each(function () { + var $this = $(this).addClass('vertical-tabs__panes'); + var focusID = $this.find(':hidden.vertical-tabs__active-tab').val(); + var tab_focus; + + // Check if there are some details that can be converted to + // vertical-tabs. + var $details = $this.find('> details'); + if ($details.length === 0) { + return; + } + + // Create the tab column. + var tab_list = $('
      '); + $this.wrap('
      ').before(tab_list); + + // Transform each details into a tab. + $details.each(function () { + var $that = $(this); + var vertical_tab = new Drupal.verticalTab({ + title: $that.find('> summary').text(), + details: $that + }); + tab_list.append(vertical_tab.item); + $that + .removeClass('collapsed') + // prop() can't be used on browsers not supporting details element, + // the style won't apply to them if prop() is used. + .attr('open', true) + .addClass('vertical-tabs__pane') + .data('verticalTab', vertical_tab); + if (this.id === focusID) { + tab_focus = $that; + } + }); + + $(tab_list).find('> li').eq(0).addClass('first'); + $(tab_list).find('> li').eq(-1).addClass('last'); + + if (!tab_focus) { + // If the current URL has a fragment and one of the tabs contains an + // element that matches the URL fragment, activate that tab. + var $locationHash = $this.find(window.location.hash); + if (window.location.hash && $locationHash.length) { + tab_focus = $locationHash.closest('.vertical-tabs__pane'); + } + else { + tab_focus = $this.find('> .vertical-tabs__pane').eq(0); + } + } + if (tab_focus.length) { + tab_focus.data('verticalTab').focus(); + } + }); + } + }; + + /** + * The vertical tab object represents a single tab within a tab group. + * + * @constructor + * + * @param {object} settings + * Settings object. + * @param {string} settings.title + * The name of the tab. + * @param {jQuery} settings.details + * The jQuery object of the details element that is the tab pane. + * + * @fires event:summaryUpdated + * + * @listens event:summaryUpdated + */ + Drupal.verticalTab = function (settings) { + var self = this; + $.extend(this, settings, Drupal.theme('verticalTab', settings)); + + this.link.attr('href', '#' + settings.details.attr('id')); + + this.link.on('click', function (e) { + e.preventDefault(); + self.focus(); + }); + + // Keyboard events added: + // Pressing the Enter key will open the tab pane. + this.link.on('keydown', function (event) { + if (event.keyCode === 13) { + event.preventDefault(); + self.focus(); + // Set focus on the first input field of the visible details/tab pane. + $('.vertical-tabs__pane :input:visible:enabled').eq(0).trigger('focus'); + } + }); + + this.details + .on('summaryUpdated', function () { + self.updateSummary(); + }) + .trigger('summaryUpdated'); + }; + + Drupal.verticalTab.prototype = { + + /** + * Displays the tab's content pane. + */ + focus: function () { + this.details + .siblings('.vertical-tabs__pane') + .each(function () { + var tab = $(this).data('verticalTab'); + tab.details.hide(); + tab.item.removeClass('is-selected'); + }) + .end() + .show() + .siblings(':hidden.vertical-tabs__active-tab') + .val(this.details.attr('id')); + this.item.addClass('is-selected'); + // Mark the active tab for screen readers. + $('#active-vertical-tab').remove(); + this.link.append('' + Drupal.t('(active tab)') + ''); + }, + + /** + * Updates the tab's summary. + */ + updateSummary: function () { + this.summary.html(this.details.drupalGetSummary()); + }, + + /** + * Shows a vertical tab pane. + * + * @return {Drupal.verticalTab} + * The verticalTab instance. + */ + tabShow: function () { + // Display the tab. + this.item.show(); + // Show the vertical tabs. + this.item.closest('.js-form-type-vertical-tabs').show(); + // Update .first marker for items. We need recurse from parent to retain + // the actual DOM element order as jQuery implements sortOrder, but not + // as public method. + this.item.parent().children('.vertical-tabs__menu-item').removeClass('first') + .filter(':visible').eq(0).addClass('first'); + // Display the details element. + this.details.removeClass('vertical-tab--hidden').show(); + // Focus this tab. + this.focus(); + return this; + }, + + /** + * Hides a vertical tab pane. + * + * @return {Drupal.verticalTab} + * The verticalTab instance. + */ + tabHide: function () { + // Hide this tab. + this.item.hide(); + // Update .first marker for items. We need recurse from parent to retain + // the actual DOM element order as jQuery implements sortOrder, but not + // as public method. + this.item.parent().children('.vertical-tabs__menu-item').removeClass('first') + .filter(':visible').eq(0).addClass('first'); + // Hide the details element. + this.details.addClass('vertical-tab--hidden').hide(); + // Focus the first visible tab (if there is one). + var $firstTab = this.details.siblings('.vertical-tabs__pane:not(.vertical-tab--hidden)').eq(0); + if ($firstTab.length) { + $firstTab.data('verticalTab').focus(); + } + // Hide the vertical tabs (if no tabs remain). + else { + this.item.closest('.js-form-type-vertical-tabs').hide(); + } + return this; + } + }; + + /** + * Theme function for a vertical tab. + * + * @param {object} settings + * An object with the following keys: + * @param {string} settings.title + * The name of the tab. + * + * @return {object} + * This function has to return an object with at least these keys: + * - item: The root tab jQuery element + * - link: The anchor tag that acts as the clickable area of the tab + * (jQuery version) + * - summary: The jQuery element that contains the tab summary + */ + Drupal.theme.verticalTab = function (settings) { + var tab = {}; + tab.item = $('
    • ') + .append(tab.link = $('') + .append(tab.title = $('').text(settings.title)) + .append(tab.summary = $('') + ) + ); + return tab; + }; + +})(jQuery, Drupal, drupalSettings); diff --git a/core/modules/big_pipe/js/big_pipe.es6.js b/core/modules/big_pipe/js/big_pipe.es6.js new file mode 100644 index 0000000000..cdfd766f13 --- /dev/null +++ b/core/modules/big_pipe/js/big_pipe.es6.js @@ -0,0 +1,110 @@ +/** + * @file + * Renders BigPipe placeholders using Drupal's Ajax system. + */ + +(function ($, Drupal, drupalSettings) { + + 'use strict'; + + /** + * Executes Ajax commands in