diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 5da51e8..cc3fd71 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -233,6 +233,15 @@ drupal.machine-name:
- core/drupalSettings
- core/drupal.form
+drupal.message:
+ version: VERSION
+ js:
+ misc/message.js: {}
+ dependencies:
+ - core/jquery
+ - core/drupal
+ - core/drupal.debounce
+
drupal.progress:
version: VERSION
js:
diff --git a/core/lib/Drupal/Core/Render/Element/StatusMessages.php b/core/lib/Drupal/Core/Render/Element/StatusMessages.php
index 530457f..cd3bf52 100644
--- a/core/lib/Drupal/Core/Render/Element/StatusMessages.php
+++ b/core/lib/Drupal/Core/Render/Element/StatusMessages.php
@@ -75,19 +75,17 @@ public static function generatePlaceholder(array $element) {
public static function renderMessages($type) {
$render = [];
$messages = drupal_get_messages($type);
- if ($messages) {
- // Render the messages.
- $render = [
- '#theme' => 'status_messages',
- // @todo Improve when https://www.drupal.org/node/2278383 lands.
- '#message_list' => $messages,
- '#status_headings' => [
- 'status' => t('Status message'),
- 'error' => t('Error message'),
- 'warning' => t('Warning message'),
- ],
- ];
- }
+ // Render the messages.
+ $render = [
+ '#theme' => 'status_messages',
+ // @todo Improve when https://www.drupal.org/node/2278383 lands.
+ '#message_list' => $messages,
+ '#status_headings' => [
+ 'status' => t('Status message'),
+ 'error' => t('Error message'),
+ 'warning' => t('Warning message'),
+ ],
+ ];
return $render;
}
diff --git a/core/misc/announce.js b/core/misc/announce.js
index acf850a..cd1ee5b 100644
--- a/core/misc/announce.js
+++ b/core/misc/announce.js
@@ -50,21 +50,23 @@
/**
* Concatenates announcements to a single string; appends to the live region.
*/
- function announce() {
+ function processAnnounce() {
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 (announcements.length) {
+ // 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';
+ }
}
}
@@ -81,8 +83,13 @@
// The live text area is updated. Allow the AT to announce the text.
liveElement.setAttribute('aria-busy', 'false');
}
+
}
+ // 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.
+ var debouncedProcessAnnounce = debounce(processAnnounce, 200);
+
/**
* Triggers audio UAs to read the supplied text.
*
@@ -96,25 +103,24 @@
*
* @param {string} text
* A string to be read by the UA.
- * @param {string} [priority='polite']
+ * @param {string} priority
* 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)());
+ if (typeof text === 'string') {
+ // 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
+ });
+ debouncedProcessAnnounce(announcements);
+ }
+ else {
+ throw new Error(Drupal.t('"text" passed Drupal.announce must be a string.'));
+ }
};
}(Drupal, Drupal.debounce));
diff --git a/core/misc/message.js b/core/misc/message.js
new file mode 100644
index 0000000..50e8ec4
--- /dev/null
+++ b/core/misc/message.js
@@ -0,0 +1,110 @@
+/**
+ * @file
+ * Message API.
+ */
+(function ($, Drupal, debounce) {
+
+ 'use strict';
+
+ var messages = [];
+ var messagesElement;
+ var debouncedProcessMessages;
+ var messageWrapperSelector = '[data-drupal-messages]';
+
+ /**
+ * Builds a div element with the aria-live attribute and attaches it to the DOM.
+ */
+ Drupal.behaviors.drupalMessage = {
+ attach: function () {
+ if (!messagesElement) {
+ var $messagesWrapper = $(messageWrapperSelector);
+ if (!$messagesWrapper.length) {
+ throw new Error(Drupal.t('There is no element with a [data-drupal-messages] attribute'));
+ }
+ messagesElement = $messagesWrapper[0];
+ }
+ }
+ };
+
+ // Debounce the function in case Drupal.message() is used in a loop.
+ debouncedProcessMessages = debounce(processMessages, 100);
+
+ /**
+ * Displays all queued messages in one pass instead of one after the other.
+ * @see debouncedProcessMessages
+ */
+ function processMessages() {
+ var text = [];
+
+ messages.reverse().forEach(function (message) {
+ text.unshift(Drupal.theme('message', message));
+ });
+ if (text.length) {
+ messagesElement.innerHTML += text.join('');
+ }
+ // Reset the messages array after the messages are processed.
+ messages = [];
+
+ }
+
+ Drupal.message = {};
+
+ /**
+ * Displays a message on the page.
+ *
+ * @param {string} message
+ * The message to display
+ * @param {string} type
+ * Message type, can be either 'status', 'error' or 'warning'.
+ * Default to 'status'
+ * @param {string} context
+ * The context of the message, used for removing messages again.
+ */
+ Drupal.message.add = function (message, type, context) {
+ if (typeof message === 'string') {
+ // Save the text and priority into a closure variable. Multiple simultaneous
+ // announcements will be concatenated and read in sequence.
+ messages.push({
+ context: context || 'default',
+ text: message,
+ type: type || 'status'
+ });
+ debouncedProcessMessages();
+ }
+ };
+
+ /**
+ * Removes messages from the page.
+ *
+ * @param {string} context
+ * The message context to remove
+ * @param {string} type
+ * Message type, can be either 'status', 'error' or 'warning'.
+ */
+ Drupal.message.remove = function (context, type) {
+ var selector = '.js-messages-context--' + (context || 'default');
+ if (type) {
+ selector = '.messages--' + (type || 'status') + selector;
+ }
+ $(selector).remove();
+ };
+
+ /**
+ * Theme function for a message.
+ *
+ * @param {object} message
+ * The message object.
+ * @param {string} message.text
+ * The message text.
+ * @param {string} message.type
+ * The message type.
+ * @param {string} message.context
+ * The message context.
+ * @return {string}
+ * A string representing a DOM fragment.
+ */
+ Drupal.theme.message = function (message) {
+ return '
' + message.text + '
';
+ };
+
+}(jQuery, Drupal, Drupal.debounce));
diff --git a/core/modules/system/templates/status-messages.html.twig b/core/modules/system/templates/status-messages.html.twig
index 73bd9b7..e8f64e0 100644
--- a/core/modules/system/templates/status-messages.html.twig
+++ b/core/modules/system/templates/status-messages.html.twig
@@ -23,25 +23,27 @@
* @ingroup themeable
*/
#}
+
{% for type, messages in message_list %}
{% if type == 'error' %}
{% endif %}
- {% if status_headings[type] %}
-
{{ status_headings[type] }}
- {% endif %}
- {% if messages|length > 1 %}
-
- {% for message in messages %}
- - {{ message }}
- {% endfor %}
-
- {% else %}
- {{ messages|first }}
- {% endif %}
+ {% if status_headings[type] %}
+
{{ status_headings[type] }}
+ {% endif %}
+ {% if messages|length > 1 %}
+
+ {% for message in messages %}
+ - {{ message }}
+ {% endfor %}
+
+ {% else %}
+ {{ messages|first }}
+ {% endif %}
{% if type == 'error' %}
{% endif %}
{% endfor %}
+
diff --git a/core/modules/system/tests/modules/js_message_test/js/js_message_test.js b/core/modules/system/tests/modules/js_message_test/js/js_message_test.js
new file mode 100644
index 0000000..b38a1c2
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js/js_message_test.js
@@ -0,0 +1,40 @@
+/**
+ * @file
+ * Testing behavior for JSMessageTest.
+ */
+
+(function ($, message) {
+
+ 'use strict';
+
+ /**
+ * @type {Drupal~behavior}
+ *
+ * @prop {Drupal~behaviorAttach} attach
+ * Add click listeners that show and remove links with context and type.
+ */
+ Drupal.behaviors.js_message_test = {
+ attach: function (context) {
+ $('.show-link').once('show-msg').on('click', function (e) {
+ e.preventDefault();
+ var type = e.currentTarget.getAttribute('data-type');
+ var context = e.currentTarget.getAttribute('data-context');
+ message.add('Msg-' + context + '-' + type, type, context);
+ });
+ $('.remove-link').once('remove-msg').on('click', function (e) {
+ e.preventDefault();
+ var type = e.currentTarget.getAttribute('data-type');
+ var context = e.currentTarget.getAttribute('data-context');
+ message.remove(context, type);
+ });
+ $('.show-multiple').once('show-msg').on('click', function (e) {
+ e.preventDefault();
+ for (var i = 0; i < 10; i++) {
+ message.add('Msg-' + i, 'status', 'context-' + i);
+ }
+
+ });
+ }
+ };
+
+})(jQuery, Drupal.message);
diff --git a/core/modules/system/tests/modules/js_message_test/js_message_test.info.yml b/core/modules/system/tests/modules/js_message_test/js_message_test.info.yml
new file mode 100644
index 0000000..e8bc73b
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js_message_test.info.yml
@@ -0,0 +1,6 @@
+name: 'JS Message test module'
+type: module
+description: 'Module for the JSMessageTest test.'
+package: Testing
+version: VERSION
+core: 8.x
diff --git a/core/modules/system/tests/modules/js_message_test/js_message_test.libraries.yml b/core/modules/system/tests/modules/js_message_test/js_message_test.libraries.yml
new file mode 100644
index 0000000..53da0f9
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js_message_test.libraries.yml
@@ -0,0 +1,7 @@
+show_message:
+ version: VERSION
+ js:
+ js/js_message_test.js: {}
+ dependencies:
+ - core/drupal.message
+ - core/jquery.once
diff --git a/core/modules/system/tests/modules/js_message_test/js_message_test.routing.yml b/core/modules/system/tests/modules/js_message_test/js_message_test.routing.yml
new file mode 100644
index 0000000..4c325e8
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/js_message_test.routing.yml
@@ -0,0 +1,7 @@
+js_message_test.links:
+ path: '/js_message_test_link'
+ defaults:
+ _controller: '\Drupal\js_message_test\Controller\JSMessageTestController::messageLinks'
+ _title: 'JsMessageLinks'
+ requirements:
+ _access: 'TRUE'
diff --git a/core/modules/system/tests/modules/js_message_test/src/Controller/JSMessageTestController.php b/core/modules/system/tests/modules/js_message_test/src/Controller/JSMessageTestController.php
new file mode 100644
index 0000000..239d4ef
--- /dev/null
+++ b/core/modules/system/tests/modules/js_message_test/src/Controller/JSMessageTestController.php
@@ -0,0 +1,71 @@
+ "Show-$context-$type",
+ 'url' => Url::fromRoute('js_message_test.links'),
+ 'attributes' => [
+ 'id' => "show-$context-$type",
+ 'data-context' => $context,
+ 'data-type' => $type,
+ 'class' => ['show-link'],
+ ],
+ ];
+ $links["remove-$context-$type"] = [
+ 'title' => "Remove-$context-$type",
+ 'url' => Url::fromRoute('js_message_test.links'),
+ 'attributes' => [
+ 'id' => "remove-$context-$type",
+ 'data-context' => $context,
+ 'data-type' => $type,
+ 'class' => ['remove-link'],
+ ],
+ ];
+ }
+ $links["remove-$context"] = [
+ 'title' => "Remove-$context-all",
+ 'url' => Url::fromRoute('js_message_test.links'),
+ 'attributes' => [
+ 'id' => "remove-$context",
+ 'data-context' => $context,
+ 'class' => ['remove-link'],
+ ],
+ ];
+ }
+ $links['show-multi'] = [
+ 'title' => "Show Multiple",
+ 'url' => Url::fromRoute('js_message_test.links'),
+ 'attributes' => [
+ 'class' => ['show-multiple'],
+ ],
+ ];
+ return [
+ '#theme' => 'links',
+ '#links' => $links,
+ '#attached' => [
+ 'library' => [
+ 'js_message_test/show_message',
+ ],
+ ],
+ ];
+ }
+
+}
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php
new file mode 100644
index 0000000..0387b01
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/Core/JsMessageTest.php
@@ -0,0 +1,142 @@
+assertSession();
+ $this->drupalGet('js_message_test_link');
+ $web_assert->elementExists('css', '[data-drupal-messages]');
+
+ foreach ($this->msgContexts as $context) {
+ foreach ($this->msgTypes as $type) {
+ $this->clickLink("Show-$context-$type");
+ $selector = ".messages.messages--$type.js-messages.js-messages-context--$context";
+ $msg_element = $web_assert->waitForElementVisible('css', $selector);
+ $this->assertNotEmpty($msg_element, "Message element visible: $selector");
+ $web_assert->elementContains('css', $selector, "Msg-$context-$type");
+ // Click all remove links except the one that will remove the message.
+ $this->clickAllRemoveLinksExcept($context, $type);
+ // Confirm the message was not removed.
+ $web_assert->elementContains('css', $selector, "Msg-$context-$type");
+ // Click the remove links that should remove the message.
+ $this->clickLink("Remove-$context-$type");
+ $web_assert->elementNotExists('css', $selector);
+ }
+ }
+
+ // Test removing all messages of a specific context.
+ $this->clickLink("Show-context1-error");
+ $this->clickLink("Show-context2-error");
+ $this->clickLink("Show-context1-warning");
+ $this->clickLink("Show-context2-warning");
+ $this->waitForMessageElement('warning', 'context2');
+ $this->assertCurrentMessages([
+ 'Msg-context1-error',
+ 'Msg-context2-error',
+ 'Msg-context1-warning',
+ 'Msg-context2-warning',
+ ]);
+ $this->clickLink('Remove-context1-all');
+ $web_assert->assertWaitOnAjaxRequest();
+ $this->assertCurrentMessages([
+ 'Msg-context2-error',
+ 'Msg-context2-warning',
+ ]);
+ $this->clickLink('Remove-context2-all');
+ $this->assertCurrentMessages([]);
+
+ // Test adding multiple messages at once.
+ // @see processMessages()
+ $this->clickLink('Show Multiple');
+ $this->waitForMessageElement('default', 'context-9');
+
+ $current_messages = [];
+ for ($i = 0; $i < 10; $i++) {
+ $current_messages[] = "Msg-$i";
+ }
+ $this->assertCurrentMessages($current_messages);
+ }
+
+ /**
+ * Clicks all remove message links except for combination specified.
+ *
+ * @param string $exclude_context
+ * The message context to exclude.
+ * @param string $exclude_type
+ * The message type to exclude.
+ */
+ protected function clickAllRemoveLinksExcept($exclude_context, $exclude_type) {
+ foreach ($this->msgContexts as $context) {
+ foreach ($this->msgTypes as $type) {
+ if ($context !== $exclude_context || $type !== $exclude_type) {
+ $this->clickLink("Remove-$context-$type");
+ }
+ }
+ }
+ }
+
+ /**
+ * Asserts that currently shown messages match expected messages.
+ *
+ * @param array $expected_messages
+ * Expected messages.
+ */
+ private function assertCurrentMessages($expected_messages) {
+ $current_messages = [];
+ $message_divs = $this->getSession()->getPage()->findAll('css', '.messages');
+ foreach ($message_divs as $message_div) {
+ /** @var \Behat\Mink\Element\NodeElement $message_div */
+ $current_messages[] = $message_div->getText();
+ }
+ $this->assertEquals($expected_messages, $current_messages);
+ }
+
+ /**
+ * Waits for an element message to be visible.
+ *
+ * @param string $type
+ * The type of the message.
+ * @param string $context
+ * The context of the message.
+ */
+ protected function waitForMessageElement($type, $context) {
+ $last_msg_element = $this->assertSession()->waitForElementVisible('css', ".messages.messages--$type.js-messages.js-messages-context--$context");
+ $this->assertNotEmpty($last_msg_element, "Message element visible.");
+ }
+
+}
diff --git a/core/themes/classy/templates/misc/status-messages.html.twig b/core/themes/classy/templates/misc/status-messages.html.twig
index bc8fd10..0d059f3 100644
--- a/core/themes/classy/templates/misc/status-messages.html.twig
+++ b/core/themes/classy/templates/misc/status-messages.html.twig
@@ -21,7 +21,7 @@
* - class: HTML classes.
*/
#}
-{{ attach_library('classy/messages') }}
+
{% block messages %}
{% for type, messages in message_list %}
{%
@@ -54,3 +54,4 @@
{% set attributes = attributes.removeClass(classes) %}
{% endfor %}
{% endblock messages %}
+