Commit 02a10b31 authored by webchick's avatar webchick

Issue #675446 by mgifford, RobLoach, amateescu, nod_, longwave, oxyc,...

Issue #675446 by mgifford, RobLoach, amateescu, nod_, longwave, oxyc, rteijeiro, tomyouds, Jelle_S, mcrittenden, Sutharsan, hansyg, Angry Dan, clemens.tolboom, droplet | Dave Reid: Change notice: Use jQuery UI Autocomplete.
parent fcea2f9e
......@@ -2158,20 +2158,8 @@ function form_process_autocomplete($element, &$form_state) {
if ($access) {
$element['#attributes']['class'][] = 'form-autocomplete';
$element['#attached']['library'][] = array('system', 'drupal.autocomplete');
// Provide a hidden element for the JavaScript behavior to bind to. Since
// this element is for client-side functionality only, do not process input.
// @todo Refactor autocomplete.js to accept drupalSettings instead of
// requiring extraneous markup.
$element['autocomplete'] = array(
'#type' => 'hidden',
'#input' => FALSE,
'#value' => $path,
'#disabled' => TRUE,
'#attributes' => array(
'class' => array('autocomplete'),
'id' => $element['#id'] . '-autocomplete',
),
);
// Provide a data attribute for the JavaScript behavior to bind to.
$element['#attributes']['data-autocomplete-path'] = $path;
}
return $element;
}
......
(function ($) {
(function ($, Drupal) {
"use strict";
/**
* Attaches the autocomplete behavior to all required fields.
*/
Drupal.behaviors.autocomplete = {
attach: function (context, settings) {
var acdb = [];
$(context).find('input.autocomplete').once('autocomplete', function () {
var uri = this.value;
if (!acdb[uri]) {
acdb[uri] = new Drupal.ACDB(uri);
}
var $input = $('#' + this.id.substr(0, this.id.length - 13))
.prop('autocomplete', 'OFF')
.attr('aria-autocomplete', 'list');
$($input[0].form).submit(Drupal.autocompleteSubmit);
$input.parent()
.attr('role', 'application')
.append($('<span class="visually-hidden" aria-live="assertive"></span>')
.attr('id', $input[0].id + '-autocomplete-aria-live')
);
new Drupal.jsAC($input, acdb[uri]);
});
}
};
var autocomplete;
/**
* Prevents the form from submitting if the suggestions popup is open
* and closes the suggestions popup when doing so.
* Helper splitting terms from the autocomplete value.
*
* @param {String} value
*
* @return {Array}
*/
Drupal.autocompleteSubmit = function () {
var $autocomplete = $('#autocomplete');
if ($autocomplete.length !== 0) {
$autocomplete[0].owner.hidePopup();
}
return $autocomplete.length === 0;
};
function autocompleteSplitValues (value) {
// We will match the value against comma-seperated terms.
var result = [];
var quote = false;
var current = '';
var valueLength = value.length;
var i, character;
/**
* An AutoComplete object.
*/
Drupal.jsAC = function ($input, db) {
var ac = this;
this.input = $input[0];
this.ariaLive = $('#' + this.input.id + '-autocomplete-aria-live');
this.db = db;
$input
.keydown(function (event) { return ac.onkeydown(this, event); })
.keyup(function (event) { ac.onkeyup(this, event); })
.blur(function () { ac.hidePopup(); ac.db.cancel(); });
};
/**
* Handler for the "keydown" event.
*/
Drupal.jsAC.prototype.onkeydown = function (input, e) {
if (!e) {
e = window.event;
for (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;
}
}
switch (e.keyCode) {
case 40: // down arrow.
e.preventDefault();
this.selectDown();
break;
case 38: // up arrow.
e.preventDefault();
this.selectUp();
break;
default: // All other keys.
return true;
if (value.length > 0) {
result.push($.trim(current));
}
};
return result;
}
/**
* Handler for the "keyup" event.
* Returns the last value of an multi-value textfield.
*
* @param {String} terms
*
* @return {String}
*/
Drupal.jsAC.prototype.onkeyup = function (input, e) {
if (!e) {
e = window.event;
}
switch (e.keyCode) {
case 16: // Shift.
case 17: // Ctrl.
case 18: // Alt.
case 20: // Caps lock.
case 33: // Page up.
case 34: // Page down.
case 35: // End.
case 36: // Home.
case 37: // Left arrow.
case 38: // Up arrow.
case 39: // Right arrow.
case 40: // Down arrow.
return true;
case 9: // Tab.
case 13: // Enter.
case 27: // Esc.
this.hidePopup(e.keyCode);
return true;
default: // All other keys.
if (input.value.length > 0 && !input.readOnly) {
this.populatePopup();
}
else {
this.hidePopup(e.keyCode);
}
return true;
}
};
function extractLastTerm (terms) {
return autocomplete.splitValues(terms).pop();
}
/**
* Puts the currently highlighted suggestion into the autocomplete field.
* The search handler is called before a search is performed.
*
* @param {Object} event
*
* @return {Boolean}
*/
Drupal.jsAC.prototype.select = function (node) {
this.input.value = $(node).data('autocompleteValue');
};
function searchHandler (event) {
// Only search when the term is two characters or larger.
var term = autocomplete.extractLastTerm(event.target.value);
return term.length >= autocomplete.minLength;
}
/**
* Highlights the next suggestion.
* jQuery UI autocomplete source callback.
*
* @param {Object} request
* @param {Function} response
*/
Drupal.jsAC.prototype.selectDown = function () {
if (this.selected && this.selected.nextSibling) {
this.highlight(this.selected.nextSibling);
function sourceData (request, response) {
var elementId = this.element.attr('id');
if (!(elementId in autocomplete.cache)) {
autocomplete.cache[elementId] = {};
}
else if (this.popup) {
var lis = $(this.popup).find('li');
if (lis.length > 0) {
this.highlight(lis.get(0));
/**
* Filter through the suggestions removing all terms already tagged and
* display the available terms to the user.
*
* @param {Object} suggestions
*/
function showSuggestions (suggestions) {
var tagged = autocomplete.splitValues(request.term);
for (var i = 0, il = tagged.length; i < il; i++) {
var index = suggestions.indexOf(tagged[i]);
if (index >= 0) {
suggestions.splice(index, 1);
}
}
response(suggestions);
}
};
/**
* Highlights the previous suggestion.
*/
Drupal.jsAC.prototype.selectUp = function () {
if (this.selected && this.selected.previousSibling) {
this.highlight(this.selected.previousSibling);
}
};
/**
* Transforms the data object into an array and update autocomplete results.
*
* @param {Object} data
*/
function sourceCallbackHandler (data) {
autocomplete.cache[elementId][term] = data;
/**
* Highlights a suggestion.
*/
Drupal.jsAC.prototype.highlight = function (node) {
// Unhighlights a suggestion for "keyup" and "keydown" events.
if (this.selected !== false) {
$(this.selected).removeClass('selected');
// Send the new string array of terms to the jQuery UI list.
showSuggestions(data);
}
$(node).addClass('selected');
this.selected = node;
$(this.ariaLive).html($(this.selected).html());
};
/**
* Unhighlights a suggestion.
*/
Drupal.jsAC.prototype.unhighlight = function (node) {
$(node).removeClass('selected');
this.selected = false;
$(this.ariaLive).empty();
};
// Get the desired term and construct the autocomplete URL for it.
var term = autocomplete.extractLastTerm(request.term);
/**
* Hides the autocomplete suggestions.
*/
Drupal.jsAC.prototype.hidePopup = function (keycode) {
// Select item if the right key or mousebutton was pressed.
if (this.selected && ((keycode && keycode !== 46 && keycode !== 8 && keycode !== 27) || !keycode)) {
this.input.value = $(this.selected).data('autocompleteValue');
// Check if the term is already cached.
if (autocomplete.cache[elementId].hasOwnProperty(term)) {
showSuggestions(autocomplete.cache[elementId][term]);
}
// Hide popup.
var popup = this.popup;
if (popup) {
this.popup = null;
$(popup).fadeOut('fast', function () { $(popup).remove(); });
else {
var options = $.extend({ success: sourceCallbackHandler, data: { q: term } }, autocomplete.ajax);
/*jshint validthis:true */
$.ajax(this.element.attr('data-autocomplete-path'), options);
}
this.selected = false;
$(this.ariaLive).empty();
};
}
/**
* Positions the suggestions popup and starts a search.
* Handles an autocompletefocus event.
*
* @return {Boolean}
*/
Drupal.jsAC.prototype.populatePopup = function () {
var $input = $(this.input);
var position = $input.position();
// Show popup.
if (this.popup) {
$(this.popup).remove();
}
this.selected = false;
this.popup = $('<div id="autocomplete"></div>')[0];
this.popup.owner = this;
$(this.popup).css({
top: parseInt(position.top + this.input.offsetHeight, 10) + 'px',
left: parseInt(position.left, 10) + 'px',
width: $input.innerWidth() + 'px',
display: 'none'
});
$input.before(this.popup);
// Do search.
this.db.owner = this;
this.db.search(this.input.value);
};
function focusHandler () {
return false;
}
/**
* Fills the suggestion popup with any matches received.
* Handles an autocompleteselect event.
*
* @param {Object} event
* @param {Object} ui
*
* @return {Boolean}
*/
Drupal.jsAC.prototype.found = function (matches) {
// If no value in the textfield, do not show the popup.
if (!this.input.value.length) {
return false;
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 + '"');
}
// Prepare matches.
var ac = this;
var ul = $('<ul></ul>')
.on('mousedown', 'li', function (e) { ac.select(this); })
.on('mouseover', 'li', function (e) { ac.highlight(this); })
.on('mouseout', 'li', function (e) { ac.unhighlight(this); });
for (var key in matches) {
if (matches.hasOwnProperty(key)) {
$('<li></li>')
.html($('<div></div>').html(matches[key]))
.data('autocompleteValue', key)
.appendTo(ul);
}
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;
}
// Show popup with matches, if any.
if (this.popup) {
if (ul.children().length) {
$(this.popup).empty().append(ul).show();
$(this.ariaLive).html(Drupal.t('Autocomplete popup'));
/**
* Attaches the autocomplete behavior to all required fields.
*/
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) {
// Use jQuery UI Autocomplete on the textfield.
$autocomplete.autocomplete(autocomplete.options);
}
else {
$(this.popup).css({ visibility: 'hidden' });
this.hidePopup();
},
detach: function (context, settings, trigger) {
if (trigger === 'unload') {
$(context).find('input.form-autocomplete')
.removeOnce('autocomplete')
.autocomplete('destroy');
}
}
};
Drupal.jsAC.prototype.setStatus = function (status) {
switch (status) {
case 'begin':
$(this.input).addClass('throbbing');
$(this.ariaLive).html(Drupal.t('Searching for matches...'));
break;
case 'cancel':
case 'error':
case 'found':
$(this.input).removeClass('throbbing');
break;
}
};
/**
* An AutoComplete DataBase object.
*/
Drupal.ACDB = function (uri) {
this.uri = uri;
this.delay = 300;
this.cache = {};
};
/**
* Performs a cached and delayed search.
* Autocomplete object implementation.
*/
Drupal.ACDB.prototype.search = function (searchString) {
var db = this;
this.searchString = searchString;
// See if this string needs to be searched for anyway.
searchString = searchString.replace(/^\s+|\s+$/, '');
if (searchString.length <= 0 ||
searchString.charAt(searchString.length - 1) === ',') {
return;
}
// See if this key has been searched for before.
if (this.cache[searchString]) {
return this.owner.found(this.cache[searchString]);
autocomplete = {
cache: {},
// Exposes methods to allow overriding by contrib.
minLength: 1,
splitValues: autocompleteSplitValues,
extractLastTerm: extractLastTerm,
// jQuery UI autocomplete options.
options: {
source: sourceData,
focus: focusHandler,
search: searchHandler,
select: selectHandler
},
ajax: {
dataType: 'json'
}
// Initiate delayed search.
if (this.timer) {
clearTimeout(this.timer);
}
this.timer = setTimeout(function () {
db.owner.setStatus('begin');
// Ajax GET request for autocompletion.
$.ajax({
type: 'GET',
url: db.uri,
data: {
q: searchString
},
dataType: 'json',
success: function (matches) {
if (typeof matches.status === 'undefined' || matches.status !== 0) {
db.cache[searchString] = matches;
// Verify if these are still the matches the user wants to see.
if (db.searchString === searchString) {
db.owner.found(matches);
}
db.owner.setStatus('found');
}
},
error: function (xmlhttp) {
throw new Drupal.AjaxError(xmlhttp, db.uri);
}
});
}, this.delay);
};
/**
* Cancels the current autocomplete request.
*/
Drupal.ACDB.prototype.cancel = function () {
if (this.owner) {
this.owner.setStatus('cancel');
}
if (this.timer) {
clearTimeout(this.timer);
}
this.searchString = '';
};
Drupal.autocomplete = autocomplete;
})(jQuery);
})(jQuery, Drupal);
......@@ -100,7 +100,7 @@ public function getMatches($field, $instance, $entity_type, $entity_id = '', $pr
if (strpos($key, ',') !== FALSE || strpos($key, '"') !== FALSE) {
$key = '"' . str_replace('"', '""', $key) . '"';
}
$matches[$prefix . $key] = $label;
$matches[] = array('value' => $prefix . $key, 'label' => $label);
}
}
}
......
......@@ -79,21 +79,24 @@ function testEntityReferenceAutocompletion() {
// We should get both entities in a JSON encoded string.
$input = '10/';
$data = $this->getAutocompleteResult('single', $input);
$this->assertIdentical($data[$entity_1->name->value . ' (1)'], check_plain($entity_1->name->value), 'Autocomplete returned the first matching entity');
$this->assertIdentical($data[$entity_2->name->value . ' (2)'], check_plain($entity_2->name->value), 'Autocomplete returned the second matching entity');
$this->assertIdentical($data[0]['label'], check_plain($entity_1->name->value), 'Autocomplete returned the first matching entity');
$this->assertIdentical($data[1]['label'], check_plain($entity_2->name->value), 'Autocomplete returned the second matching entity');
// Try to autocomplete a entity label that matches the first entity.
// We should only get the first entity in a JSON encoded string.
$input = '10/16';
$data = $this->getAutocompleteResult('single', $input);
$target = array($entity_1->name->value . ' (1)' => check_plain($entity_1->name->value));
$this->assertIdentical($data, $target, 'Autocomplete returns only the expected matching entity.');
$target = array(
'value' => $entity_1->name->value . ' (1)',
'label' => check_plain($entity_1->name->value),
);
$this->assertIdentical(reset($data), $target, 'Autocomplete returns only the expected matching entity.');
// Try to autocomplete a entity label that matches the second entity, and
// the first entity is already typed in the autocomplete (tags) widget.
$input = $entity_1->name->value . ' (1), 10/17';
$data = $this->getAutocompleteResult('tags', $input);
$this->assertIdentical($data[$entity_1->name->value . ' (1), ' . $entity_2->name->value . ' (2)'], check_plain($entity_2->name->value), 'Autocomplete returned the second matching entity');
$this->assertIdentical($data[0]['label'], check_plain($entity_2->name->value), 'Autocomplete returned the second matching entity');
// Try to autocomplete a entity label with both a comma and a slash.
$input = '"label with, and / t';
......@@ -103,8 +106,11 @@ function testEntityReferenceAutocompletion() {
if (strpos($entity_3->name->value, ',') !== FALSE || strpos($entity_3->name->value, '"') !== FALSE) {
$n = '"' . str_replace('"', '""', $entity_3->name->value) . ' (3)"';
}
$target = array($n => check_plain($entity_3->name->value));
$this->assertIdentical($data, $target, 'Autocomplete returns an entity label containing a comma and a slash.');
$target = array(
'value' => $n,
'label' => check_plain($entity_3->name->value),
);
$this->assertIdentical(reset($data), $target, 'Autocomplete returns an entity label containing a comma and a slash.');
}
/**
......
......@@ -131,7 +131,7 @@ public function testAuthorAutocomplete() {
$this->drupalGet('node/add/page');
$result = $this->xpath('//input[@id = "edit-name-autocomplete"]');
$result = $this->xpath('//input[@id="edit-name" and contains(@data-autocomplete-path, "user/autocomplete")]');
$this->assertEqual(count($result), 0, 'No autocompletion without access user profiles.');
$admin_user = $this->drupalCreateUser(array('administer nodes', 'create page content', 'access user profiles'));
......@@ -139,8 +139,7 @@ public function testAuthorAutocomplete() {
$this->drupalGet('node/add/page');
$result = $this->xpath('//input[@id = "edit-name-autocomplete"]');
$this->assertEqual((string) $result[0]['value'], url('user/autocomplete'));
$result = $this->xpath('//input[@id="edit-name" and contains(@data-autocomplete-path, "user/autocomplete")]');
$this->assertEqual(count($result), 1, 'Ensure that the user does have access to the autocompletion');
}
......
......@@ -8,25 +8,6 @@
*
* @see autocomplete.js
*/
/* Suggestion list */
#autocomplete {
border: 1px solid;
overflow: hidden;
position: absolute;
z-index: 100;
}
#autocomplete ul {
list-style: none;
list-style-image: none;
margin: 0;
padding: 0;
}
#autocomplete li {
background: #fff;
color: #000;
cursor: default;
white-space: pre;
}
/* Animated throbber */
.js input.form-autocomplete {
......@@ -37,10 +18,10 @@
.js[dir="rtl"] input.form-autocomplete {
background-position: 0% 2px;
}
.js input.throbbing {
.js input.form-autocomplete.ui-autocomplete-loading {
background-position: 100% -18px; /* LTR */
}
.js[dir="rtl"] input.throbbing {
.js[dir="rtl"] input.form-autocomplete.ui-autocomplete-loading {
background-position: 0% -18px;
}
......
......@@ -192,9 +192,10 @@ label button.link {
* @see autocomplete.js
*/
/* Suggestion list */
#autocomplete li.selected {
.ui-autocomplete li.ui-menu-item a.ui-state-focus, .autocomplete li.ui-menu-item a.ui-state-hover {
background: #0072b9;
color: #fff;
margin: 0;
}
/**
......
......@@ -133,20 +133,18 @@ function testGroupElements() {
public function testFormAutocomplete() {
$this->drupalGet('form-test/autocomplete');
$result = $this->xpath('//input[@id = "edit-autocomplete-1-autocomplete"]');
$result = $this->xpath('//input[@id="edit-autocomplete-1" and contains(@data-autocomplete-path, "form-test/autocomplete-1")]');
$this->assertEqual(count($result), 0, 'Ensure that the user does not have access to the autocompletion');
$result = $this->xpath('//input[@id="edit-autocomplete-2" and contains(@data-autocomplete-path, "form-test/autocomplete-2/value")]');
$this->assertEqual(count($result), 0, 'Ensure that the user does not have access to the autocompletion');
$result = $this->xpath('//input[@id = "edit-autocomplete-2-autocomplete"]');
$this->assertEqual(count($result), 0, 'Ensure that the user did not had access to the autocompletion');
$user = $this->drupalCreateUser(array('access autocomplete test'));
$this->drupalLogin($user);
$this->drupalGet('form-test/autocomplete');
$result = $this->xpath('//input[@id = "edit-autocomplete-1-autocomplete"]');
$this->assertEqual((string) $result[0]['value'], url('form-test/autocomplete-1'));
$result = $this->xpath('//input[@id="edit-autocomplete-1" and contains(@data-autocomplete-path, "form-test/autocomplete-1")]');
$this->assertEqual(count($result), 1, 'Ensure that the user does have access to the autocompletion');
$result = $this->xpath('//input[@id = "edit-autocomplete-2-autocomplete"]');
$this->assertEqual((string) $result[0]['value'], url('form-test/autocomplete-2/value'));
$result = $this->xpath('//input[@id="edit-autocomplete-2" and contains(@data-autocomplete-path, "form-test/autocomplete-2/value")]');
$this->assertEqual(count($result), 1, 'Ensure that the user does have access to the autocompletion');
}
......
......@@ -1096,7 +1096,9 @@ function system_library_info() {
'dependencies' => array(
array('system', 'jquery'),
array('system', 'drupal'),
array('system', 'drupalSettings'),
array('system', 'drupal.ajax'),
array('system', 'jquery.ui.autocomplete'),
),
);
......
......@@ -200,7 +200,7 @@ protected function getMatchingTerms($tags_typed, array $vids, $tag_last) {
if (strpos($name, ',') !== FALSE || strpos($name, '"') !== FALSE) {
$name = '"' . str_replace('"', '""', $name) . '"';
}
$matches[$prefix . $name] = String::checkPlain($term->label());
$matches[] = array('value' => $prefix . $name, 'label' => String::checkPlain($term->label()));
}
return $matches;
}
......
......@@ -220,13 +220,13 @@ function testNodeTermCreationAndDeletion() {
// The term will be quoted, and the " will be encoded in unicode (\u0022).
$input = substr($term_objects['term3']->label(), 0, 3);
$json = $this->drupalGet('taxonomy/autocomplete/node/taxonomy_' . $this->vocabulary->id(), array('query' => array('q' => $input)));
$this->assertEqual($json, '{"\u0022' . $term_objects['term3']->label() . '\u0022":"' . $term_objects['term3']->label() . '"}', format_string('Autocomplete returns term %term_name after typing the first 3 letters.', array('%term_name' => $term_objects['term3']->label())));
$this->assertEqual($json, '[{"value":"\u0022' . $term_objects['term3']->label() . '\u0022","label":"' . $term_objects['term3']->label() . '"}]', format_string('Autocomplete returns term %term_name after typing the first 3 letters.', array('%term_name' => $term_objects['term3']->label())));
// Test autocomplete on term 4 - it is alphanumeric only, so no extra