diff --git a/composer.lock b/composer.lock
index 720778a35f..5c360b82a2 100644
--- a/composer.lock
+++ b/composer.lock
@@ -528,7 +528,7 @@
             "dist": {
                 "type": "path",
                 "url": "core",
-                "reference": "a6f2bdf8cfc3d8ade8caa5473be482b20d1c6f83"
+                "reference": "ba5be6dff8298b985fcddac4e31e9c64c32b82c9"
             },
             "require": {
                 "asm89/stack-cors": "^1.1",
diff --git a/core/.eslintrc.json b/core/.eslintrc.json
index 5a982db5a2..6d5c3d73a2 100644
--- a/core/.eslintrc.json
+++ b/core/.eslintrc.json
@@ -20,7 +20,8 @@
     "Modernizr": true,
     "Popper": true,
     "Sortable": true,
-    "CKEDITOR": true
+    "CKEDITOR": true,
+    "DrupalAutocomplete": true
   },
   "settings": {
     "react": {
diff --git a/core/assets/vendor/fetch/fetch.umd.js b/core/assets/vendor/fetch/fetch.umd.js
new file mode 100644
index 0000000000..1b2131e29a
--- /dev/null
+++ b/core/assets/vendor/fetch/fetch.umd.js
@@ -0,0 +1,620 @@
+(function (global, factory) {
+  typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
+  typeof define === 'function' && define.amd ? define(['exports'], factory) :
+  (factory((global.WHATWGFetch = {})));
+}(this, (function (exports) { 'use strict';
+
+  var global =
+    (typeof globalThis !== 'undefined' && globalThis) ||
+    (typeof self !== 'undefined' && self) ||
+    (typeof global !== 'undefined' && global);
+
+  var support = {
+    searchParams: 'URLSearchParams' in global,
+    iterable: 'Symbol' in global && 'iterator' in Symbol,
+    blob:
+      'FileReader' in global &&
+      'Blob' in global &&
+      (function() {
+        try {
+          new Blob();
+          return true
+        } catch (e) {
+          return false
+        }
+      })(),
+    formData: 'FormData' in global,
+    arrayBuffer: 'ArrayBuffer' in global
+  };
+
+  function isDataView(obj) {
+    return obj && DataView.prototype.isPrototypeOf(obj)
+  }
+
+  if (support.arrayBuffer) {
+    var viewClasses = [
+      '[object Int8Array]',
+      '[object Uint8Array]',
+      '[object Uint8ClampedArray]',
+      '[object Int16Array]',
+      '[object Uint16Array]',
+      '[object Int32Array]',
+      '[object Uint32Array]',
+      '[object Float32Array]',
+      '[object Float64Array]'
+    ];
+
+    var isArrayBufferView =
+      ArrayBuffer.isView ||
+      function(obj) {
+        return obj && viewClasses.indexOf(Object.prototype.toString.call(obj)) > -1
+      };
+  }
+
+  function normalizeName(name) {
+    if (typeof name !== 'string') {
+      name = String(name);
+    }
+    if (/[^a-z0-9\-#$%&'*+.^_`|~!]/i.test(name) || name === '') {
+      throw new TypeError('Invalid character in header field name')
+    }
+    return name.toLowerCase()
+  }
+
+  function normalizeValue(value) {
+    if (typeof value !== 'string') {
+      value = String(value);
+    }
+    return value
+  }
+
+  // Build a destructive iterator for the value list
+  function iteratorFor(items) {
+    var iterator = {
+      next: function() {
+        var value = items.shift();
+        return {done: value === undefined, value: value}
+      }
+    };
+
+    if (support.iterable) {
+      iterator[Symbol.iterator] = function() {
+        return iterator
+      };
+    }
+
+    return iterator
+  }
+
+  function Headers(headers) {
+    this.map = {};
+
+    if (headers instanceof Headers) {
+      headers.forEach(function(value, name) {
+        this.append(name, value);
+      }, this);
+    } else if (Array.isArray(headers)) {
+      headers.forEach(function(header) {
+        this.append(header[0], header[1]);
+      }, this);
+    } else if (headers) {
+      Object.getOwnPropertyNames(headers).forEach(function(name) {
+        this.append(name, headers[name]);
+      }, this);
+    }
+  }
+
+  Headers.prototype.append = function(name, value) {
+    name = normalizeName(name);
+    value = normalizeValue(value);
+    var oldValue = this.map[name];
+    this.map[name] = oldValue ? oldValue + ', ' + value : value;
+  };
+
+  Headers.prototype['delete'] = function(name) {
+    delete this.map[normalizeName(name)];
+  };
+
+  Headers.prototype.get = function(name) {
+    name = normalizeName(name);
+    return this.has(name) ? this.map[name] : null
+  };
+
+  Headers.prototype.has = function(name) {
+    return this.map.hasOwnProperty(normalizeName(name))
+  };
+
+  Headers.prototype.set = function(name, value) {
+    this.map[normalizeName(name)] = normalizeValue(value);
+  };
+
+  Headers.prototype.forEach = function(callback, thisArg) {
+    for (var name in this.map) {
+      if (this.map.hasOwnProperty(name)) {
+        callback.call(thisArg, this.map[name], name, this);
+      }
+    }
+  };
+
+  Headers.prototype.keys = function() {
+    var items = [];
+    this.forEach(function(value, name) {
+      items.push(name);
+    });
+    return iteratorFor(items)
+  };
+
+  Headers.prototype.values = function() {
+    var items = [];
+    this.forEach(function(value) {
+      items.push(value);
+    });
+    return iteratorFor(items)
+  };
+
+  Headers.prototype.entries = function() {
+    var items = [];
+    this.forEach(function(value, name) {
+      items.push([name, value]);
+    });
+    return iteratorFor(items)
+  };
+
+  if (support.iterable) {
+    Headers.prototype[Symbol.iterator] = Headers.prototype.entries;
+  }
+
+  function consumed(body) {
+    if (body.bodyUsed) {
+      return Promise.reject(new TypeError('Already read'))
+    }
+    body.bodyUsed = true;
+  }
+
+  function fileReaderReady(reader) {
+    return new Promise(function(resolve, reject) {
+      reader.onload = function() {
+        resolve(reader.result);
+      };
+      reader.onerror = function() {
+        reject(reader.error);
+      };
+    })
+  }
+
+  function readBlobAsArrayBuffer(blob) {
+    var reader = new FileReader();
+    var promise = fileReaderReady(reader);
+    reader.readAsArrayBuffer(blob);
+    return promise
+  }
+
+  function readBlobAsText(blob) {
+    var reader = new FileReader();
+    var promise = fileReaderReady(reader);
+    reader.readAsText(blob);
+    return promise
+  }
+
+  function readArrayBufferAsText(buf) {
+    var view = new Uint8Array(buf);
+    var chars = new Array(view.length);
+
+    for (var i = 0; i < view.length; i++) {
+      chars[i] = String.fromCharCode(view[i]);
+    }
+    return chars.join('')
+  }
+
+  function bufferClone(buf) {
+    if (buf.slice) {
+      return buf.slice(0)
+    } else {
+      var view = new Uint8Array(buf.byteLength);
+      view.set(new Uint8Array(buf));
+      return view.buffer
+    }
+  }
+
+  function Body() {
+    this.bodyUsed = false;
+
+    this._initBody = function(body) {
+      /*
+        fetch-mock wraps the Response object in an ES6 Proxy to
+        provide useful test harness features such as flush. However, on
+        ES5 browsers without fetch or Proxy support pollyfills must be used;
+        the proxy-pollyfill is unable to proxy an attribute unless it exists
+        on the object before the Proxy is created. This change ensures
+        Response.bodyUsed exists on the instance, while maintaining the
+        semantic of setting Request.bodyUsed in the constructor before
+        _initBody is called.
+      */
+      this.bodyUsed = this.bodyUsed;
+      this._bodyInit = body;
+      if (!body) {
+        this._bodyText = '';
+      } else if (typeof body === 'string') {
+        this._bodyText = body;
+      } else if (support.blob && Blob.prototype.isPrototypeOf(body)) {
+        this._bodyBlob = body;
+      } else if (support.formData && FormData.prototype.isPrototypeOf(body)) {
+        this._bodyFormData = body;
+      } else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) {
+        this._bodyText = body.toString();
+      } else if (support.arrayBuffer && support.blob && isDataView(body)) {
+        this._bodyArrayBuffer = bufferClone(body.buffer);
+        // IE 10-11 can't handle a DataView body.
+        this._bodyInit = new Blob([this._bodyArrayBuffer]);
+      } else if (support.arrayBuffer && (ArrayBuffer.prototype.isPrototypeOf(body) || isArrayBufferView(body))) {
+        this._bodyArrayBuffer = bufferClone(body);
+      } else {
+        this._bodyText = body = Object.prototype.toString.call(body);
+      }
+
+      if (!this.headers.get('content-type')) {
+        if (typeof body === 'string') {
+          this.headers.set('content-type', 'text/plain;charset=UTF-8');
+        } else if (this._bodyBlob && this._bodyBlob.type) {
+          this.headers.set('content-type', this._bodyBlob.type);
+        } else if (support.searchParams && URLSearchParams.prototype.isPrototypeOf(body)) {
+          this.headers.set('content-type', 'application/x-www-form-urlencoded;charset=UTF-8');
+        }
+      }
+    };
+
+    if (support.blob) {
+      this.blob = function() {
+        var rejected = consumed(this);
+        if (rejected) {
+          return rejected
+        }
+
+        if (this._bodyBlob) {
+          return Promise.resolve(this._bodyBlob)
+        } else if (this._bodyArrayBuffer) {
+          return Promise.resolve(new Blob([this._bodyArrayBuffer]))
+        } else if (this._bodyFormData) {
+          throw new Error('could not read FormData body as blob')
+        } else {
+          return Promise.resolve(new Blob([this._bodyText]))
+        }
+      };
+
+      this.arrayBuffer = function() {
+        if (this._bodyArrayBuffer) {
+          var isConsumed = consumed(this);
+          if (isConsumed) {
+            return isConsumed
+          }
+          if (ArrayBuffer.isView(this._bodyArrayBuffer)) {
+            return Promise.resolve(
+              this._bodyArrayBuffer.buffer.slice(
+                this._bodyArrayBuffer.byteOffset,
+                this._bodyArrayBuffer.byteOffset + this._bodyArrayBuffer.byteLength
+              )
+            )
+          } else {
+            return Promise.resolve(this._bodyArrayBuffer)
+          }
+        } else {
+          return this.blob().then(readBlobAsArrayBuffer)
+        }
+      };
+    }
+
+    this.text = function() {
+      var rejected = consumed(this);
+      if (rejected) {
+        return rejected
+      }
+
+      if (this._bodyBlob) {
+        return readBlobAsText(this._bodyBlob)
+      } else if (this._bodyArrayBuffer) {
+        return Promise.resolve(readArrayBufferAsText(this._bodyArrayBuffer))
+      } else if (this._bodyFormData) {
+        throw new Error('could not read FormData body as text')
+      } else {
+        return Promise.resolve(this._bodyText)
+      }
+    };
+
+    if (support.formData) {
+      this.formData = function() {
+        return this.text().then(decode)
+      };
+    }
+
+    this.json = function() {
+      return this.text().then(JSON.parse)
+    };
+
+    return this
+  }
+
+  // HTTP methods whose capitalization should be normalized
+  var methods = ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'POST', 'PUT'];
+
+  function normalizeMethod(method) {
+    var upcased = method.toUpperCase();
+    return methods.indexOf(upcased) > -1 ? upcased : method
+  }
+
+  function Request(input, options) {
+    if (!(this instanceof Request)) {
+      throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.')
+    }
+
+    options = options || {};
+    var body = options.body;
+
+    if (input instanceof Request) {
+      if (input.bodyUsed) {
+        throw new TypeError('Already read')
+      }
+      this.url = input.url;
+      this.credentials = input.credentials;
+      if (!options.headers) {
+        this.headers = new Headers(input.headers);
+      }
+      this.method = input.method;
+      this.mode = input.mode;
+      this.signal = input.signal;
+      if (!body && input._bodyInit != null) {
+        body = input._bodyInit;
+        input.bodyUsed = true;
+      }
+    } else {
+      this.url = String(input);
+    }
+
+    this.credentials = options.credentials || this.credentials || 'same-origin';
+    if (options.headers || !this.headers) {
+      this.headers = new Headers(options.headers);
+    }
+    this.method = normalizeMethod(options.method || this.method || 'GET');
+    this.mode = options.mode || this.mode || null;
+    this.signal = options.signal || this.signal;
+    this.referrer = null;
+
+    if ((this.method === 'GET' || this.method === 'HEAD') && body) {
+      throw new TypeError('Body not allowed for GET or HEAD requests')
+    }
+    this._initBody(body);
+
+    if (this.method === 'GET' || this.method === 'HEAD') {
+      if (options.cache === 'no-store' || options.cache === 'no-cache') {
+        // Search for a '_' parameter in the query string
+        var reParamSearch = /([?&])_=[^&]*/;
+        if (reParamSearch.test(this.url)) {
+          // If it already exists then set the value with the current time
+          this.url = this.url.replace(reParamSearch, '$1_=' + new Date().getTime());
+        } else {
+          // Otherwise add a new '_' parameter to the end with the current time
+          var reQueryString = /\?/;
+          this.url += (reQueryString.test(this.url) ? '&' : '?') + '_=' + new Date().getTime();
+        }
+      }
+    }
+  }
+
+  Request.prototype.clone = function() {
+    return new Request(this, {body: this._bodyInit})
+  };
+
+  function decode(body) {
+    var form = new FormData();
+    body
+      .trim()
+      .split('&')
+      .forEach(function(bytes) {
+        if (bytes) {
+          var split = bytes.split('=');
+          var name = split.shift().replace(/\+/g, ' ');
+          var value = split.join('=').replace(/\+/g, ' ');
+          form.append(decodeURIComponent(name), decodeURIComponent(value));
+        }
+      });
+    return form
+  }
+
+  function parseHeaders(rawHeaders) {
+    var headers = new Headers();
+    // Replace instances of \r\n and \n followed by at least one space or horizontal tab with a space
+    // https://tools.ietf.org/html/rfc7230#section-3.2
+    var preProcessedHeaders = rawHeaders.replace(/\r?\n[\t ]+/g, ' ');
+    // Avoiding split via regex to work around a common IE11 bug with the core-js 3.6.0 regex polyfill
+    // https://github.com/github/fetch/issues/748
+    // https://github.com/zloirock/core-js/issues/751
+    preProcessedHeaders
+      .split('\r')
+      .map(function(header) {
+        return header.indexOf('\n') === 0 ? header.substr(1, header.length) : header
+      })
+      .forEach(function(line) {
+        var parts = line.split(':');
+        var key = parts.shift().trim();
+        if (key) {
+          var value = parts.join(':').trim();
+          headers.append(key, value);
+        }
+      });
+    return headers
+  }
+
+  Body.call(Request.prototype);
+
+  function Response(bodyInit, options) {
+    if (!(this instanceof Response)) {
+      throw new TypeError('Please use the "new" operator, this DOM object constructor cannot be called as a function.')
+    }
+    if (!options) {
+      options = {};
+    }
+
+    this.type = 'default';
+    this.status = options.status === undefined ? 200 : options.status;
+    this.ok = this.status >= 200 && this.status < 300;
+    this.statusText = 'statusText' in options ? options.statusText : '';
+    this.headers = new Headers(options.headers);
+    this.url = options.url || '';
+    this._initBody(bodyInit);
+  }
+
+  Body.call(Response.prototype);
+
+  Response.prototype.clone = function() {
+    return new Response(this._bodyInit, {
+      status: this.status,
+      statusText: this.statusText,
+      headers: new Headers(this.headers),
+      url: this.url
+    })
+  };
+
+  Response.error = function() {
+    var response = new Response(null, {status: 0, statusText: ''});
+    response.type = 'error';
+    return response
+  };
+
+  var redirectStatuses = [301, 302, 303, 307, 308];
+
+  Response.redirect = function(url, status) {
+    if (redirectStatuses.indexOf(status) === -1) {
+      throw new RangeError('Invalid status code')
+    }
+
+    return new Response(null, {status: status, headers: {location: url}})
+  };
+
+  exports.DOMException = global.DOMException;
+  try {
+    new exports.DOMException();
+  } catch (err) {
+    exports.DOMException = function(message, name) {
+      this.message = message;
+      this.name = name;
+      var error = Error(message);
+      this.stack = error.stack;
+    };
+    exports.DOMException.prototype = Object.create(Error.prototype);
+    exports.DOMException.prototype.constructor = exports.DOMException;
+  }
+
+  function fetch(input, init) {
+    return new Promise(function(resolve, reject) {
+      var request = new Request(input, init);
+
+      if (request.signal && request.signal.aborted) {
+        return reject(new exports.DOMException('Aborted', 'AbortError'))
+      }
+
+      var xhr = new XMLHttpRequest();
+
+      function abortXhr() {
+        xhr.abort();
+      }
+
+      xhr.onload = function() {
+        var options = {
+          status: xhr.status,
+          statusText: xhr.statusText,
+          headers: parseHeaders(xhr.getAllResponseHeaders() || '')
+        };
+        options.url = 'responseURL' in xhr ? xhr.responseURL : options.headers.get('X-Request-URL');
+        var body = 'response' in xhr ? xhr.response : xhr.responseText;
+        setTimeout(function() {
+          resolve(new Response(body, options));
+        }, 0);
+      };
+
+      xhr.onerror = function() {
+        setTimeout(function() {
+          reject(new TypeError('Network request failed'));
+        }, 0);
+      };
+
+      xhr.ontimeout = function() {
+        setTimeout(function() {
+          reject(new TypeError('Network request failed'));
+        }, 0);
+      };
+
+      xhr.onabort = function() {
+        setTimeout(function() {
+          reject(new exports.DOMException('Aborted', 'AbortError'));
+        }, 0);
+      };
+
+      function fixUrl(url) {
+        try {
+          return url === '' && global.location.href ? global.location.href : url
+        } catch (e) {
+          return url
+        }
+      }
+
+      xhr.open(request.method, fixUrl(request.url), true);
+
+      if (request.credentials === 'include') {
+        xhr.withCredentials = true;
+      } else if (request.credentials === 'omit') {
+        xhr.withCredentials = false;
+      }
+
+      if ('responseType' in xhr) {
+        if (support.blob) {
+          xhr.responseType = 'blob';
+        } else if (
+          support.arrayBuffer &&
+          request.headers.get('Content-Type') &&
+          request.headers.get('Content-Type').indexOf('application/octet-stream') !== -1
+        ) {
+          xhr.responseType = 'arraybuffer';
+        }
+      }
+
+      if (init && typeof init.headers === 'object' && !(init.headers instanceof Headers)) {
+        Object.getOwnPropertyNames(init.headers).forEach(function(name) {
+          xhr.setRequestHeader(name, normalizeValue(init.headers[name]));
+        });
+      } else {
+        request.headers.forEach(function(value, name) {
+          xhr.setRequestHeader(name, value);
+        });
+      }
+
+      if (request.signal) {
+        request.signal.addEventListener('abort', abortXhr);
+
+        xhr.onreadystatechange = function() {
+          // DONE (success or failure)
+          if (xhr.readyState === 4) {
+            request.signal.removeEventListener('abort', abortXhr);
+          }
+        };
+      }
+
+      xhr.send(typeof request._bodyInit === 'undefined' ? null : request._bodyInit);
+    })
+  }
+
+  fetch.polyfill = true;
+
+  if (!global.fetch) {
+    global.fetch = fetch;
+    global.Headers = Headers;
+    global.Request = Request;
+    global.Response = Response;
+  }
+
+  exports.Headers = Headers;
+  exports.Request = Request;
+  exports.Response = Response;
+  exports.fetch = fetch;
+
+  Object.defineProperty(exports, '__esModule', { value: true });
+
+})));
diff --git a/core/composer.json b/core/composer.json
index 46542ac447..518cd7d22d 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -54,6 +54,7 @@
         "drupal/action": "self.version",
         "drupal/aggregator": "self.version",
         "drupal/automated_cron": "self.version",
+        "drupal/drupal_autocomplete": "self.version",
         "drupal/bartik": "self.version",
         "drupal/ban": "self.version",
         "drupal/basic_auth": "self.version",
diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 4fc57f2db5..b21ec8383f 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -164,6 +164,11 @@ drupal.collapse:
     - core/drupal.form
     - core/jquery.once
 
+drupal.customevent:
+  version: VERSION
+  js:
+    misc/polyfills/customevent.js: { weight: -20 }
+
 drupal.date:
   version: VERSION
   js:
@@ -269,6 +274,11 @@ drupal.dropbutton:
     - core/drupalSettings
     - core/jquery.once
 
+drupal.element.closest:
+  version: VERSION
+  js:
+    misc/polyfills/element.closest.js: { weight: -20 }
+
 drupal.entity-form:
   version: VERSION
   js:
@@ -276,6 +286,17 @@ drupal.entity-form:
   dependencies:
     - core/drupal.form
 
+drupal.fetch:
+  version: "3.4.0"
+  license:
+    name: MIT
+    url: https://raw.githubusercontent.com/github/fetch/v3.4.1/LICENSE
+    gpl-compatible: true
+  js:
+    assets/vendor/fetch/fetch.umd.js: {}
+  dependencies:
+    - core/es6-promise
+
 drupal.form:
   version: VERSION
   js:
@@ -565,7 +586,7 @@ jquery.ui.autocomplete:
     - core/jquery.ui.widget
     - core/jquery.ui.position
     - core/jquery.ui.menu
-  deprecated: *jquery_ui_unused_deprecated
+  deprecated: The "%library_id%" asset library is deprecated in drupal:9.2.0 and is removed from drupal:10.0.0. See https://www.drupal.org/node/3083715
 
 jquery.ui.button:
   version: *jquery_ui_version
diff --git a/core/lib/Drupal/Core/Entity/Element/EntityAutocomplete.php b/core/lib/Drupal/Core/Entity/Element/EntityAutocomplete.php
index 1083f1127a..0ce0910749 100644
--- a/core/lib/Drupal/Core/Entity/Element/EntityAutocomplete.php
+++ b/core/lib/Drupal/Core/Entity/Element/EntityAutocomplete.php
@@ -3,6 +3,7 @@
 namespace Drupal\Core\Entity\Element;
 
 use Drupal\Component\Utility\Crypt;
+use Drupal\Component\Utility\NestedArray;
 use Drupal\Component\Utility\Tags;
 use Drupal\Core\Entity\EntityInterface;
 use Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface;
@@ -188,6 +189,26 @@ public static function processEntityAutocomplete(array &$element, FormStateInter
       'selection_settings_key' => $selection_settings_key,
     ];
 
+    // Create attribute that will be used for client-side enforcement of field
+    // cardinality.
+    $element_parents_in_form = array_slice($element['#parents'], 0, count($element['#parents']) - 2);
+    $element_within_form = NestedArray::getValue($complete_form, $element_parents_in_form, $key_exists);
+
+    // If the input is a inside a multiple value widget, limit cardinality to 1
+    // as there is a dedicated input for each allowed value.
+    if (!empty($element_within_form['widget']['#theme']) && $element_within_form['widget']['#theme'] === 'field_multiple_value_form') {
+      $cardinality = 1;
+    }
+    elseif (!empty($element_within_form['widget']['#cardinality'])) {
+      $cardinality = $element_within_form['widget']['#cardinality'];
+    }
+    else {
+      // A Cardinality of -1 is considered unlimited.
+      $cardinality = -1;
+    }
+
+    $element['#attributes']['data-autocomplete-cardinality'] = $cardinality;
+
     return $element;
   }
 
diff --git a/core/lib/Drupal/Core/Form/FormState.php b/core/lib/Drupal/Core/Form/FormState.php
index 8264371c83..d2f8cafb4a 100644
--- a/core/lib/Drupal/Core/Form/FormState.php
+++ b/core/lib/Drupal/Core/Form/FormState.php
@@ -1109,11 +1109,13 @@ public function clearErrors() {
   public function getError(array $element) {
     if ($errors = $this->getErrors()) {
       $parents = [];
-      foreach ($element['#parents'] as $parent) {
-        $parents[] = $parent;
-        $key = implode('][', $parents);
-        if (isset($errors[$key])) {
-          return $errors[$key];
+      if (!empty($element['#parents'])) {
+        foreach ($element['#parents'] as $parent) {
+          $parents[] = $parent;
+          $key = implode('][', $parents);
+          if (isset($errors[$key])) {
+            return $errors[$key];
+          }
         }
       }
     }
diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt
index ef568bce5d..bed8c71c55 100644
--- a/core/misc/cspell/dictionary.txt
+++ b/core/misc/cspell/dictionary.txt
@@ -338,6 +338,7 @@ curle
 curlopt
 currenttime
 currentuser
+customevent
 customly
 customrequest
 cweagans
@@ -393,6 +394,7 @@ denormalizers
 denormalizes
 denormalizing
 denyall
+denylist
 dependee
 dependee's
 dependees
@@ -462,6 +464,7 @@ droping
 dropzone
 drup
 drupalci
+drupalautocomplete
 drupaldatetime
 drupalget
 drupalimage
@@ -909,6 +912,7 @@ mattfarina
 maxage
 maxdepth
 maximumred
+maxitems
 maxlifetime
 maxsize
 maynot
@@ -934,6 +938,7 @@ mikey
 milli
 mimeheaders
 mimetypes
+minchar
 minifyzombies
 minimatch
 minimise
@@ -1133,7 +1138,9 @@ overwritable
 owasp
 pageable
 pagecache
+pagedown
 pagetop
+pageup
 pageviews
 pagina
 pangram
@@ -1956,6 +1963,7 @@ yygroup
 yyyymm
 zartan
 zendframework
+zindex
 zipf's
 znor
 zomg
diff --git a/core/misc/polyfills/customevent.es6.js b/core/misc/polyfills/customevent.es6.js
new file mode 100644
index 0000000000..be85a56cb8
--- /dev/null
+++ b/core/misc/polyfills/customevent.es6.js
@@ -0,0 +1,30 @@
+/**
+ * @file
+ * Provides a polyfill for CustomEvent.
+ *
+ * This is needed for Internet Explorer 11.
+ *
+ * This has been copied from MDN Web Docs code samples. Code samples in the MDN
+ * Web Docs are licensed under CC0.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent#Polyfill
+ * @see https://developer.mozilla.org/en-US/docs/MDN/About#Code_samples_and_snippets
+ */
+// eslint-disable-next-line func-names
+(function () {
+  if (typeof window.CustomEvent === 'function') return false;
+
+  function CustomEvent(event, params) {
+    params = params || { bubbles: false, cancelable: false, detail: null };
+    const evt = document.createEvent('CustomEvent');
+    evt.initCustomEvent(
+      event,
+      params.bubbles,
+      params.cancelable,
+      params.detail,
+    );
+    return evt;
+  }
+
+  window.CustomEvent = CustomEvent;
+})();
diff --git a/core/misc/polyfills/customevent.js b/core/misc/polyfills/customevent.js
new file mode 100644
index 0000000000..87e371ed1c
--- /dev/null
+++ b/core/misc/polyfills/customevent.js
@@ -0,0 +1,23 @@
+/**
+* DO NOT EDIT THIS FILE.
+* See the following change record for more information,
+* https://www.drupal.org/node/2815083
+* @preserve
+**/
+
+(function () {
+  if (typeof window.CustomEvent === 'function') return false;
+
+  function CustomEvent(event, params) {
+    params = params || {
+      bubbles: false,
+      cancelable: false,
+      detail: null
+    };
+    var evt = document.createEvent('CustomEvent');
+    evt.initCustomEvent(event, params.bubbles, params.cancelable, params.detail);
+    return evt;
+  }
+
+  window.CustomEvent = CustomEvent;
+})();
\ No newline at end of file
diff --git a/core/misc/polyfills/element.closest.es6.js b/core/misc/polyfills/element.closest.es6.js
new file mode 100644
index 0000000000..2833c5759d
--- /dev/null
+++ b/core/misc/polyfills/element.closest.es6.js
@@ -0,0 +1,30 @@
+/**
+ * @file
+ * Provides a polyfill for Element.closest().
+ *
+ * This is needed for Internet Explorer 11 and Opera Mini.
+ *
+ * This has been copied from MDN Web Docs code samples. Code samples in the MDN
+ * Web Docs are licensed under CC0.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill
+ * @see https://developer.mozilla.org/en-US/docs/MDN/About#Code_samples_and_snippets
+ */
+if (!Element.prototype.matches) {
+  Element.prototype.matches =
+    Element.prototype.msMatchesSelector ||
+    Element.prototype.webkitMatchesSelector;
+}
+
+if (!Element.prototype.closest) {
+  // eslint-disable-next-line func-names
+  Element.prototype.closest = function (s) {
+    let el = this;
+
+    do {
+      if (Element.prototype.matches.call(el, s)) return el;
+      el = el.parentElement || el.parentNode;
+    } while (el !== null && el.nodeType === 1);
+    return null;
+  };
+}
diff --git a/core/misc/polyfills/element.closest.js b/core/misc/polyfills/element.closest.js
new file mode 100644
index 0000000000..c1317ce883
--- /dev/null
+++ b/core/misc/polyfills/element.closest.js
@@ -0,0 +1,23 @@
+/**
+* DO NOT EDIT THIS FILE.
+* See the following change record for more information,
+* https://www.drupal.org/node/2815083
+* @preserve
+**/
+
+if (!Element.prototype.matches) {
+  Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
+}
+
+if (!Element.prototype.closest) {
+  Element.prototype.closest = function (s) {
+    var el = this;
+
+    do {
+      if (Element.prototype.matches.call(el, s)) return el;
+      el = el.parentElement || el.parentNode;
+    } while (el !== null && el.nodeType === 1);
+
+    return null;
+  };
+}
\ No newline at end of file
diff --git a/core/modules/drupal_autocomplete/css/drupal_autocomplete.css b/core/modules/drupal_autocomplete/css/drupal_autocomplete.css
new file mode 100644
index 0000000000..d8e022044b
--- /dev/null
+++ b/core/modules/drupal_autocomplete/css/drupal_autocomplete.css
@@ -0,0 +1,29 @@
+[data-drupal-autocomplete-wrapper] {
+  position: relative;
+}
+
+[data-drupal-autocomplete-list] {
+  position: absolute;
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+[data-drupal-autocomplete-list][hidden],
+[data-drupal-autocomplete-list]:empty {
+  display: none;
+}
+[data-drupal-autocomplete-list] li {
+  display: list-item;
+}
+[data-drupal-autocomplete-list] li a:hover {
+  cursor: pointer;
+}
+
+[data-drupal-autocomplete-live-region] {
+  position: absolute !important;
+  overflow: hidden;
+  clip: rect(1px, 1px, 1px, 1px);
+  width: 1px;
+  height: 1px;
+  word-wrap: normal;
+}
diff --git a/core/modules/drupal_autocomplete/css/jqueryui.css b/core/modules/drupal_autocomplete/css/jqueryui.css
new file mode 100644
index 0000000000..14427d7247
--- /dev/null
+++ b/core/modules/drupal_autocomplete/css/jqueryui.css
@@ -0,0 +1,15 @@
+/* Styling specific to drupal_autocomplete */
+[data-drupal-autocomplete-wrapper] .ui-menu-item-wrapper {
+  position: relative;
+  display: block;
+  padding: 3px 1em 3px 0.4em;
+}
+
+[data-drupal-autocomplete-wrapper] .ui-menu-item-wrapper:hover {
+  color: #840;
+}
+
+.ui-autocomplete {
+  border: 1px solid #ccc;
+  background: #fff;
+}
diff --git a/core/modules/drupal_autocomplete/drupal_autocomplete.info.yml b/core/modules/drupal_autocomplete/drupal_autocomplete.info.yml
new file mode 100644
index 0000000000..6768c7d497
--- /dev/null
+++ b/core/modules/drupal_autocomplete/drupal_autocomplete.info.yml
@@ -0,0 +1,5 @@
+name: 'Drupal 9 Autocomplete'
+type: module
+version: VERSION
+description: 'Provides a replacement for jQuery UI Autocomplete. jQuery UI Autocomplete is deprecated in Drupal 9.2.0 and will be removed in Drupal 10.0.'
+package: 'Core (Experimental)'
diff --git a/core/modules/drupal_autocomplete/drupal_autocomplete.libraries.yml b/core/modules/drupal_autocomplete/drupal_autocomplete.libraries.yml
new file mode 100644
index 0000000000..7cc450b6a8
--- /dev/null
+++ b/core/modules/drupal_autocomplete/drupal_autocomplete.libraries.yml
@@ -0,0 +1,28 @@
+autocomplete:
+  version: VERSION
+  js:
+    js/drupalautocomplete.js: {}
+    js/autocomplete-init.js: {}
+  css:
+    component:
+      css/jqueryui.css: { weight: -1 }
+      css/drupal_autocomplete.css: {}
+  dependencies:
+    - core/drupal
+    - core/drupalSettings
+    - core/drupal.ajax
+    - core/drupal.announce
+    - core/drupal.customevent
+    - core/drupal.element.closest
+    - core/drupal.fetch
+    - core/popperjs
+    - core/jqueryui.autocomplete.styles
+    # jQuery needed for once() and event management.
+    - core/jquery
+    - core/jquery.once
+
+jqueryui.autocomplete.styles:
+  version: VERSION
+  css:
+    component:
+      /core/assets/vendor/jquery.ui/themes/base/autocomplete.css: {}
diff --git a/core/modules/drupal_autocomplete/drupal_autocomplete.module b/core/modules/drupal_autocomplete/drupal_autocomplete.module
new file mode 100644
index 0000000000..efc027b48b
--- /dev/null
+++ b/core/modules/drupal_autocomplete/drupal_autocomplete.module
@@ -0,0 +1,33 @@
+<?php
+
+/**
+ * @file
+ * Provides a replacement for jQuery UI Autocomplete.
+ */
+
+use Drupal\Core\Routing\RouteMatchInterface;
+
+/**
+ * Implements hook_help().
+ */
+function drupal_autocomplete_help($route_name, RouteMatchInterface $route_match) {
+  switch ($route_name) {
+    case 'help.page.drupal_autocomplete':
+      $output = '<h3>' . t('About') . '</h3>';
+      $output .= '<p>' . t("The Drupal 9 Autocomplete module implements a replacement for jQuery UI Autocomplete. The asset library <code>core/jquery.ui.autocomplete</code> is deprecated in drupal:9.2.0 and is removed from drupal:10.0.0. Enabling this module overrides the current core asset library to evaluate Drupal core's autocomplete,  and to assist with making any necessary style or API changes to themes or modules.") . '</p>';
+      $output .= '<p>' . t('For more information, see the <a href=":drupal_autocomplete">online documentation for the Drupal 9 Autocomplete module.</a>.', [':drupal_autocomplete' => 'https://www.drupal.org/docs/']) . '</p>';
+      return $output;
+  }
+}
+
+/**
+ * Implements hook_library_info_alter().
+ */
+function drupal_autocomplete_library_info_alter(&$libraries, $extension) {
+  if ($extension == 'core' && isset($libraries['drupal.autocomplete'])) {
+    // Remove core js.
+    $libraries['drupal.autocomplete']['js'] = [];
+    // Add this module's library as a dependency.
+    $libraries['drupal.autocomplete']['dependencies'][] = 'drupal_autocomplete/autocomplete';
+  }
+}
diff --git a/core/modules/drupal_autocomplete/js/autocomplete-init.es6.js b/core/modules/drupal_autocomplete/js/autocomplete-init.es6.js
new file mode 100644
index 0000000000..64cdd96b10
--- /dev/null
+++ b/core/modules/drupal_autocomplete/js/autocomplete-init.es6.js
@@ -0,0 +1,159 @@
+(($, Drupal, drupalSettings, DrupalAutocomplete) => {
+  Drupal.Autocomplete = {};
+  Drupal.Autocomplete.instances = [];
+  Drupal.Autocomplete.defaultOptions = {
+    // Add jQuery UI classes so the autocomplete is styled the same as its
+    // jQuery UI predecessor.
+    inputClass: 'ui-autocomplete-input',
+    ulClass: 'ui-menu ui-widget ui-widget-content ui-autocomplete ui-front',
+    loadingClass: 'ui-autocomplete-loading',
+    itemClass: 'ui-menu-item',
+    // Do not create an autocomplete-specific live region since
+    // #drupal-live-announce will be used.
+    createLiveRegion: false,
+    displayLabels: false,
+  };
+
+  /**
+   * Attaches the autocomplete behavior to fields configured for autocomplete.
+   *
+   * @type {Drupal~behavior}
+   *
+   * @prop {Drupal~behaviorAttach} attach
+   *   Attaches the autocomplete behaviors.
+   */
+  Drupal.behaviors.autocomplete = {
+    attach(context) {
+      const options = Drupal.Autocomplete.defaultOptions || {};
+      const $autoCompleteInputs = $(context)
+        .find('input.form-autocomplete')
+        .once('autocomplete-init');
+
+      /**
+       * Formats an autocomplete suggestion for display in a list item.
+       *
+       * This overrides DrupalAutocomplete.formatSuggestionItem()
+       *
+       * @param {object} suggestion
+       *   An autocomplete suggestion.
+       * @return {string|HTMLElement}
+       *   The contents of the list item.
+       */
+      function autocompleteFormatSuggestionItem(suggestion) {
+        const propertyToDisplay = this.options.displayLabels
+          ? 'label'
+          : 'value';
+
+        // Wrap the item text in an `<a>`, This tag is not added by default
+        // as it's not needed for functionality. However, Claro and Seven
+        // both have styles assuming the presence of this tag.
+        return `<a tabindex="-1" class="ui-menu-item-wrapper">${suggestion[
+          propertyToDisplay
+        ].trim()}</a>`;
+      }
+
+      /**
+       * Formats a message reporting the number of results in a search.
+       *
+       * This overrides DrupalAutocomplete.suggestionItem()
+       *
+       * @param {number} count
+       *   The number of results.
+       *
+       * @return {string}
+       *   The message to be announced by assistive technology.
+       */
+      function autocompleteResultsMessage(count) {
+        const { maxItems } = this.options;
+        if (count === 0) {
+          return Drupal.t('No results found');
+        }
+
+        const pluralMessage =
+          maxItems === count
+            ? 'There are at least @count results available. Type additional characters to refine your search.'
+            : 'There are @count results available.';
+        return Drupal.formatPlural(
+          count,
+          'There is one result available.',
+          pluralMessage,
+        );
+      }
+
+      /**
+       * Formats a message reporting a suggestion has been highlighted.
+       *
+       * This overrides DrupalAutocomplete.highlightMessage()
+       *
+       * @param {object} item
+       *   The suggestion item being highlighted.
+       *
+       * @return {string}
+       *   The message to be announced by assistive technology.
+       */
+      function autocompleteHighlightMessage(item) {
+        return Drupal.t('@item @count of @total is highlighted', {
+          '@item': item.innerText,
+          '@count': item.getAttribute('aria-posinset'),
+          '@total': this.ul.children.length,
+        });
+      }
+
+      /**
+       * Sends a message to assistive technology.
+       *
+       * This overrides DrupalAutocomplete.sendToLiveRegion()
+       *
+       * @param {string} message
+       *   The message to be announced.
+       */
+      function autocompleteSendToLiveRegion(message) {
+        Drupal.announce(message, 'assertive');
+      }
+
+      options.inputAssistiveHint = Drupal.t(
+        'When autocomplete results are available use up and down arrows to review and enter to select.  Touch device users, explore by touch or with swipe gestures.',
+      );
+
+      // Disable the creation of autocomplete-specific live regions.
+      // Drupal.announce() will be used instead.
+      options.liveRegion = false;
+
+      $autoCompleteInputs.each((index, autocompleteInput) => {
+        // The default cardinality of DrupalAutocomplete is 1. Fields in Drupal
+        // without explicitly set should be set to -1, which provides unlimited
+        // cardinality.
+        if (!autocompleteInput.hasAttribute('data-autocomplete-cardinality')) {
+          autocompleteInput.setAttribute('data-autocomplete-cardinality', '-1');
+        }
+
+        // Several DrupalAutocomplete methods are overridden so Drupal.t() and
+        // Drupal.announce() can be used without the class itself requiring
+        // Drupal.
+        const id = autocompleteInput.getAttribute('id');
+        Drupal.Autocomplete.instances[id] = new DrupalAutocomplete(
+          autocompleteInput,
+          options,
+        );
+        Drupal.Autocomplete.instances[
+          id
+        ].resultsMessage = autocompleteResultsMessage;
+        Drupal.Autocomplete.instances[
+          id
+        ].sendToLiveRegion = autocompleteSendToLiveRegion;
+        Drupal.Autocomplete.instances[
+          id
+        ].highlightMessage = autocompleteHighlightMessage;
+        Drupal.Autocomplete.instances[
+          id
+        ].formatSuggestionItem = autocompleteFormatSuggestionItem;
+      });
+    },
+    detach(context) {
+      context.querySelectorAll('input.form-autocomplete').forEach((input) => {
+        const id = input.getAttribute('id');
+        Drupal.Autocomplete.instances[id].destroy();
+      });
+    },
+  };
+})(jQuery, Drupal, drupalSettings, DrupalAutocomplete);
diff --git a/core/modules/drupal_autocomplete/js/autocomplete-init.js b/core/modules/drupal_autocomplete/js/autocomplete-init.js
new file mode 100644
index 0000000000..fccdb46af6
--- /dev/null
+++ b/core/modules/drupal_autocomplete/js/autocomplete-init.js
@@ -0,0 +1,74 @@
+/**
+* DO NOT EDIT THIS FILE.
+* See the following change record for more information,
+* https://www.drupal.org/node/2815083
+* @preserve
+**/
+
+(function ($, Drupal, drupalSettings, DrupalAutocomplete) {
+  Drupal.Autocomplete = {};
+  Drupal.Autocomplete.instances = [];
+  Drupal.Autocomplete.defaultOptions = {
+    inputClass: 'ui-autocomplete-input',
+    ulClass: 'ui-menu ui-widget ui-widget-content ui-autocomplete ui-front',
+    loadingClass: 'ui-autocomplete-loading',
+    itemClass: 'ui-menu-item',
+    createLiveRegion: false,
+    displayLabels: false
+  };
+  Drupal.behaviors.autocomplete = {
+    attach: function attach(context) {
+      var options = Drupal.Autocomplete.defaultOptions || {};
+      var $autoCompleteInputs = $(context).find('input.form-autocomplete').once('autocomplete-init');
+
+      function autocompleteFormatSuggestionItem(suggestion) {
+        var propertyToDisplay = this.options.displayLabels ? 'label' : 'value';
+        return "<a tabindex=\"-1\" class=\"ui-menu-item-wrapper\">".concat(suggestion[propertyToDisplay].trim(), "</a>");
+      }
+
+      function autocompleteResultsMessage(count) {
+        var maxItems = this.options.maxItems;
+
+        if (count === 0) {
+          return Drupal.t('No results found');
+        }
+
+        var pluralMessage = maxItems === count ? 'There are at least @count results available. Type additional characters to refine your search.' : 'There are @count results available.';
+        return Drupal.formatPlural(count, 'There is one result available.', pluralMessage);
+      }
+
+      function autocompleteHighlightMessage(item) {
+        return Drupal.t('@item @count of @total is highlighted', {
+          '@item': item.innerText,
+          '@count': item.getAttribute('aria-posinset'),
+          '@total': this.ul.children.length
+        });
+      }
+
+      function autocompleteSendToLiveRegion(message) {
+        Drupal.announce(message, 'assertive');
+      }
+
+      options.inputAssistiveHint = Drupal.t('When autocomplete results are available use up and down arrows to review and enter to select.  Touch device users, explore by touch or with swipe gestures.');
+      options.liveRegion = false;
+      $autoCompleteInputs.each(function (index, autocompleteInput) {
+        if (!autocompleteInput.hasAttribute('data-autocomplete-cardinality')) {
+          autocompleteInput.setAttribute('data-autocomplete-cardinality', '-1');
+        }
+
+        var id = autocompleteInput.getAttribute('id');
+        Drupal.Autocomplete.instances[id] = new DrupalAutocomplete(autocompleteInput, options);
+        Drupal.Autocomplete.instances[id].resultsMessage = autocompleteResultsMessage;
+        Drupal.Autocomplete.instances[id].sendToLiveRegion = autocompleteSendToLiveRegion;
+        Drupal.Autocomplete.instances[id].highlightMessage = autocompleteHighlightMessage;
+        Drupal.Autocomplete.instances[id].formatSuggestionItem = autocompleteFormatSuggestionItem;
+      });
+    },
+    detach: function detach(context) {
+      context.querySelectorAll('input.form-autocomplete').forEach(function (input) {
+        var id = input.getAttribute('id');
+        Drupal.Autocomplete.instances[id].destroy();
+      });
+    }
+  };
+})(jQuery, Drupal, drupalSettings, DrupalAutocomplete);
\ No newline at end of file
diff --git a/core/modules/drupal_autocomplete/js/drupalautocomplete.es6.js b/core/modules/drupal_autocomplete/js/drupalautocomplete.es6.js
new file mode 100644
index 0000000000..3830aeb53b
--- /dev/null
+++ b/core/modules/drupal_autocomplete/js/drupalautocomplete.es6.js
@@ -0,0 +1,884 @@
+/**
+ * @file
+ * Standalone autocomplete.
+ */
+
+/**
+ * Constructs a new instance of the DrupalAutocomplete class.
+ *
+ * This adds autocomplete functionality to a text input.
+ *
+ * @param {HTMLElement} input
+ *   The element to be used as an autocomplete.
+ *
+ * @return {DrupalAutocomplete}
+ *   Class to manage an input's autocomplete functionality.
+ */
+class DrupalAutocomplete {
+  /**
+   * Construct a new DrupalAutocomplete class.
+   *
+   * @param {Element} input
+   *   The input that will receive autocomplete functionality.
+   *
+   * @param {object} options
+   *  Autocomplete options, these will override the default options.
+   */
+  constructor(input, options = {}) {
+    this.keyCode = Object.freeze({
+      TAB: 9,
+      RETURN: 13,
+      ESC: 27,
+      SPACE: 32,
+      PAGEUP: 33,
+      PAGEDOWN: 34,
+      END: 35,
+      HOME: 36,
+      LEFT: 37,
+      UP: 38,
+      RIGHT: 39,
+      DOWN: 40,
+    });
+
+    this.input = input;
+
+    this.count = document.querySelectorAll(
+      '[data-drupal-autocomplete-input]',
+    ).length;
+    this.listboxId = `autocomplete-listbox-${this.count}`;
+
+    const defaultOptions = {
+      firstCharacterDenylist: ',',
+      minChars: 1,
+      maxItems: 10,
+      sort: false,
+      path: false,
+      displayLabels: true,
+      list: [],
+      cardinality: 1,
+      inputClass: '',
+      ulClass: '',
+      itemClass: '',
+      loadingClass: 'drupal-autocomplete-loading',
+      separatorChar: ',',
+      createLiveRegion: true,
+      listZindex: 100,
+      inputAssistiveHint:
+        'When autocomplete results are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures.',
+      minCharAssistiveHint: 'Type @count or more characters for results',
+      messages: {
+        noResults: 'No results found',
+        moreThanMaxResults:
+          'There are at least @count results available. Type additional characters to refine your search.',
+        someResults: 'There are @count results available.',
+        oneResult: 'There is one result available.',
+      },
+    };
+
+    this.options = {
+      ...defaultOptions,
+      ...options,
+      ...this.attributesToOptions(),
+    };
+
+    // Preset lists provided as strings should be converted to options.
+    if (typeof this.options.list === 'string') {
+      this.options.list = JSON.parse(this.options.list);
+    }
+
+    this.preventCloseOnBlur = false;
+    this.isOpened = false;
+    this.cache = [];
+    this.suggestionItems = [];
+    this.hasAnnouncedOnce = false;
+    this.timeOutId = null;
+
+    // Create a div that will wrap the input and suggestion list.
+    this.wrapper = document.createElement('div');
+    this.implementWrapper();
+
+    this.inputDescribedBy = this.input.getAttribute('aria-describedby');
+    this.inputHintRead = false;
+    this.implementInput();
+
+    // Create the list that will display suggestions.
+    this.ul = document.createElement('ul');
+    this.implementList();
+
+    // When applicable, create a live region for announcing suggestion results
+    // to assistive technology.
+    this.liveRegion = null;
+    this.implementLiveRegion();
+
+    // Events to add.
+    this.events = {
+      input: {
+        input: () => this.inputListener(),
+        blur: (e) => this.blurHandler(e),
+        keydown: (e) => this.inputKeyDown(e),
+      },
+      ul: {
+        mousedown: (e) => e.preventDefault(),
+        click: (e) => this.itemClick(e),
+        keydown: (e) => this.listKeyDown(e),
+        blur: (e) => this.blurHandler(e),
+      },
+    };
+
+    // If jQuery is available, use it to add events listeners. Otherwise use
+    // vanilla JavaScript.
+    if (window.jQuery) {
+      const $ = window.jQuery;
+      $(this.input).on(this.events.input);
+      $(this.ul).on(this.events.ul);
+    } else {
+      Object.keys(this.events).forEach((elementName) => {
+        Object.keys(this.events[elementName]).forEach((eventName) => {
+          this[elementName].addEventListener(
+            eventName,
+            this.events[elementName][eventName],
+          );
+        });
+      });
+    }
+
+    this.triggerEvent('autocomplete-created');
+  }
+
+  /**
+   * Sets attributes to the wrapper and inserts it in the DOM.
+   */
+  implementWrapper() {
+    this.wrapper.setAttribute('data-drupal-autocomplete-wrapper', '');
+    this.input.parentNode.appendChild(this.wrapper);
+    this.wrapper.appendChild(this.input);
+  }
+
+  /**
+   * Sets attributes to the input and inserts it in the DOM.
+   */
+  implementInput() {
+    // Add attributes to the input.
+    this.input.setAttribute('aria-autocomplete', 'list');
+    this.input.setAttribute('autocomplete', 'off');
+    this.input.setAttribute('data-drupal-autocomplete-input', '');
+    this.input.setAttribute('aria-owns', this.listboxId);
+    this.input.setAttribute('role', 'combobox');
+    this.input.setAttribute('aria-expanded', 'false');
+    if (this.options.inputClass.length > 0) {
+      this.options.inputClass
+        .split(' ')
+        .forEach((className) => this.input.classList.add(className));
+    }
+    if (!this.input.hasAttribute('id')) {
+      this.input.setAttribute(`autocomplete-input-${this.count}`);
+    }
+
+    const description = document.createElement('span');
+    description.textContent =
+      this.minCharsMessage() + this.options.inputAssistiveHint;
+    description.classList.add('visually-hidden');
+    if (this.inputDescribedBy) {
+      description.setAttribute(
+        'data-drupal-autocomplete-assistive-hint',
+        this.count,
+      );
+      document
+        .querySelector(`[id="${this.inputDescribedBy}"]`)
+        .appendChild(description);
+    } else {
+      description.setAttribute('id', `assistive-hint-${this.count}`);
+      this.input.setAttribute(
+        'aria-describedby',
+        `assistive-hint-${this.count}`,
+      );
+      this.wrapper.appendChild(description);
+    }
+  }
+
+  /**
+   * Sets attributes to the results list and inserts it in the DOM.
+   */
+  implementList() {
+    this.ul.setAttribute('role', 'listbox');
+    this.ul.setAttribute('data-drupal-autocomplete-list', '');
+    this.ul.setAttribute('id', this.listboxId);
+    this.ul.setAttribute('hidden', '');
+    this.input.parentNode.appendChild(this.ul);
+    if (this.options.ulClass.length > 0) {
+      this.options.ulClass
+        .split(' ')
+        .forEach((className) => this.ul.classList.add(className));
+    }
+  }
+
+  /**
+   * Creates a live region for reporting status to assistive technology.
+   */
+  implementLiveRegion() {
+    // If the liveRegion option is set to true, create a new live region and
+    // insert it in the autocomplete wrapper.
+    if (this.options.createLiveRegion === true) {
+      this.liveRegion = document.createElement('span');
+      this.liveRegion.setAttribute('data-drupal-autocomplete-live-region', '');
+      this.liveRegion.setAttribute('aria-live', 'assertive');
+      this.input.parentNode.appendChild(this.liveRegion);
+    }
+
+    // If the liveRegion option is a string, it should be a selector for an
+    // already-existing live region.
+    if (typeof this.options.liveRegion === 'string') {
+      this.liveRegion = document.querySelector(this.options.liveRegion);
+    }
+  }
+
+  /**
+   * Converts data-autocomplete* attributes into options.
+   *
+   * @return {object} an autocomplete options object.
+   */
+  attributesToOptions() {
+    const options = {};
+    // Any options provided in the `data-autocomplete` attribute will take
+    // precedence over those specified in `data-autocomplete-(x)`.
+    const dataAutocompleteAttributeOptions = this.input.getAttribute(
+      'data-autocomplete',
+    )
+      ? JSON.parse(this.input.getAttribute('data-autocomplete'))
+      : {};
+
+    // Loop through all of the input's attributes. Any attributes beginning with
+    // `data-autocomplete` will be added to an options object.
+    for (let i = 0; i < this.input.attributes.length; i++) {
+      if (
+        this.input.attributes[i].nodeName.includes('data-autocomplete') &&
+        this.input.attributes[i].nodeName !== 'data-autocomplete'
+      ) {
+        // Convert the data attribute name to camel case for use in the options
+        // object.
+        let optionName = this.input.attributes[i].nodeName
+          .replace('data-autocomplete-', '')
+          .split('-')
+          .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+          .join('');
+        optionName = optionName.charAt(0).toLowerCase() + optionName.slice(1);
+        options[optionName] = this.input.attributes[i].nodeValue;
+      }
+    }
+
+    return { ...options, ...dataAutocompleteAttributeOptions };
+  }
+
+  /**
+   * Handles blur events.
+   *
+   * @param {Event} e
+   *   The blur event.
+   */
+  blurHandler(e) {
+    // If an element is blurred, cancel any pending screenreader announcements
+    // as they would be specific to an element no longer in focus.
+    window.clearTimeout(this.timeOutId);
+    if (this.preventCloseOnBlur) {
+      this.preventCloseOnBlur = false;
+      e.preventDefault();
+    } else {
+      this.close();
+    }
+  }
+
+  removeAssistiveHint() {
+    if (!this.inputHintRead) {
+      if (this.inputDescribedBy) {
+        const appendedHint = document.querySelector(
+          `[data-drupal-autocomplete-assistive-hint="${this.count}"]`,
+        );
+        appendedHint.parentNode.removeChild(appendedHint);
+      } else {
+        this.input.removeAttribute('aria-describedby');
+      }
+      this.inputHintRead = true;
+    }
+  }
+
+  /**
+   * Handles keydown events on the item list.
+   *
+   * @param {Event} e
+   *   The keydown event.
+   */
+  listKeyDown(e) {
+    if (
+      !this.ul.contains(document.activeElement) ||
+      e.ctrlKey ||
+      e.altKey ||
+      e.metaKey ||
+      e.keyCode === this.keyCode.TAB
+    ) {
+      return;
+    }
+
+    this.ul.querySelectorAll('[aria-selected="true"]').forEach((li) => {
+      li.setAttribute('aria-selected', 'false');
+    });
+
+    switch (e.keyCode) {
+      case this.keyCode.SPACE:
+      case this.keyCode.RETURN:
+        this.replaceInputValue(document.activeElement.textContent);
+        this.close();
+        this.input.focus();
+        break;
+
+      case this.keyCode.ESC:
+      case this.keyCode.TAB:
+        this.input.focus();
+        this.close();
+        break;
+
+      case this.keyCode.UP:
+        this.focusPrev();
+        break;
+
+      case this.keyCode.DOWN:
+        this.focusNext();
+        break;
+
+      default:
+        break;
+    }
+
+    e.stopPropagation();
+    e.preventDefault();
+  }
+
+  /**
+   * Moves focus to the previous list item.
+   */
+  focusPrev() {
+    this.preventCloseOnBlur = true;
+    const currentItem = document.activeElement.getAttribute(
+      'data-drupal-autocomplete-item',
+    );
+    const prevIndex = parseInt(currentItem, 10) - 1;
+    const previousItem = this.ul.querySelector(
+      `[data-drupal-autocomplete-item="${prevIndex}"]`,
+    );
+
+    if (previousItem) {
+      this.highlightItem(previousItem);
+    } else {
+      this.input.focus();
+    }
+  }
+
+  /**
+   * Moves focus to the next list item.
+   */
+  focusNext() {
+    const currentItem = document.activeElement.getAttribute(
+      'data-drupal-autocomplete-item',
+    );
+    const nextIndex = parseInt(currentItem, 10) + 1;
+    const nextItem = this.ul.querySelector(
+      `[data-drupal-autocomplete-item="${nextIndex}"]`,
+    );
+    if (nextItem) {
+      this.preventCloseOnBlur = true;
+      this.highlightItem(nextItem);
+    }
+  }
+
+  /**
+   * Highlights and focuses a selected item.
+   *
+   * @param {HTMLElement} item
+   *   The list item being selected.
+   */
+  highlightItem(item) {
+    item.setAttribute('aria-selected', true);
+    item.focus();
+    this.announceHighlight(item);
+  }
+
+  /**
+   * Announces to assistive tech when an item is highlighted.
+   *
+   * @param {HTMLElement} item
+   *   The list item being selected.
+   */
+  announceHighlight(item) {
+    window.clearTimeout(this.timeOutId);
+    // Delay the announcement by 500 milliseconds. This prevents unnecessary
+    // calls when a user is navigating quickly.
+    this.timeOutId = setTimeout(
+      () => this.sendToLiveRegion(this.highlightMessage(item)),
+      500,
+    );
+  }
+
+  highlightMessage(item) {
+    return `${item.innerText} ${item.getAttribute('aria-posinset')} of ${
+      this.ul.children.length
+    } is highlighted`;
+  }
+
+  /**
+   * Handles keydown events on the autocomplete input.
+   *
+   * @param {Event} e
+   *   The keydown event.
+   */
+  inputKeyDown(e) {
+    const { keyCode } = e;
+    if (this.isOpened) {
+      if (keyCode === this.keyCode.ESC) {
+        this.close();
+      }
+      if (keyCode === this.keyCode.DOWN) {
+        e.preventDefault();
+        this.preventCloseOnBlur = true;
+        this.highlightItem(this.ul.querySelector('li'));
+      }
+    }
+    this.removeAssistiveHint();
+  }
+
+  /**
+   * Handles click events on the item list.
+   *
+   * @param {Event} e
+   *   The click event.
+   */
+  itemClick(e) {
+    const li = e.target;
+
+    if (li && e.button === 0) {
+      const selected = this.triggerEvent(
+        'autocomplete-select',
+        {
+          originalEvent: e,
+          selected: li.textContent,
+        },
+        true,
+      );
+
+      if (selected) {
+        this.replaceInputValue(li.textContent);
+        e.preventDefault();
+        this.close();
+        this.triggerEvent('autocomplete-selection-added', {
+          added: li.textContent,
+        });
+      }
+    }
+  }
+
+  /**
+   * Replaces the value of an input field when a new value is chosen.
+   *
+   * @param {string} item
+   *   The item being added to the field.
+   */
+  replaceInputValue(item) {
+    const separator = this.separator();
+    if (separator.length > 0) {
+      const before = this.previousItems(separator);
+      this.input.value = `${before}${item}`;
+    } else {
+      this.input.value = item;
+    }
+  }
+
+  /**
+   * Returns the separator character.
+   *
+   * @return {string}
+   *   The separator character or a zero-length string.
+   *   If the autocomplete input does not support multiple items or has reached
+   *   The maximum number of items that can be added, a zero-length string is
+   *   returned as no separator is needed.
+   */
+  separator() {
+    const { cardinality } = this.options;
+    const numItems = this.splitValues().length - 1;
+    return numItems < cardinality || parseInt(cardinality, 10) <= 0
+      ? this.options.separatorChar
+      : '';
+  }
+
+  /**
+   * Gets all existing items in the autocomplete input.
+   *
+   * @param {string} separator
+   *   The character separating the items.
+   *
+   * @return {string|string}
+   *   The string of existing values in the input.
+   */
+  previousItems(separator) {
+    const escapedSeparator = separator.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+    const regex = new RegExp(`^.+${escapedSeparator}\\s*|`);
+    const match = this.input.value.match(regex)[0];
+    return match && match.length > 0 ? `${match.trim()} ` : '';
+  }
+
+  /**
+   * Performs a search based on what has been typed in the input.
+   */
+  inputListener() {
+    const inputId = this.input.getAttribute('id');
+    const searchTerm = this.extractLastInputValue();
+
+    if (!(inputId in this.cache)) {
+      this.cache[inputId] = {};
+    }
+
+    if (searchTerm && searchTerm.length > 0) {
+      if (this.cache[inputId].hasOwnProperty(searchTerm)) {
+        this.suggestionItems = this.cache[inputId][searchTerm];
+        this.displayResults();
+      } else if (this.options.list.length === 0 && this.options.path) {
+        this.options.loadingClass
+          .split(' ')
+          .forEach((className) => this.input.classList.add(className));
+        fetch(this.queryUrl(searchTerm))
+          .then((response) => response.json())
+          .then((results) => {
+            this.options.loadingClass
+              .split(' ')
+              .forEach((className) => this.input.classList.remove(className));
+            this.suggestionItems = results;
+            this.displayResults();
+            this.cache[inputId][searchTerm] = results;
+          });
+      } else {
+        // If a predefined list was provided as an option, make this the
+        // suggestion items.
+        this.suggestionItems = this.options.list;
+        this.displayResults();
+      }
+    } else {
+      // If the search query is empty, provide an empty list of suggestions.
+      this.suggestionItems = [];
+      this.displayResults();
+    }
+  }
+
+  /**
+   * The URL used to search for a term.
+   *
+   * @param {string} searchTerm
+   *   The term being searched for.
+   *
+   * @return {string}
+   *   The URL to retrieve search results from.
+   */
+  queryUrl(searchTerm) {
+    return `${this.options.path}?q=${searchTerm}`;
+  }
+
+  /**
+   * Displays the results retrieved in inputListener().
+   */
+  displayResults() {
+    const typed = this.extractLastInputValue();
+    this.ul.innerHTML = '';
+    if (
+      typed &&
+      typed.length >= this.options.minChars &&
+      this.suggestionItems.length > 0
+    ) {
+      this.suggestions = this.suggestionItems.filter((item) =>
+        this.filterResults(item, typed),
+      );
+
+      if (this.options.sort !== false) {
+        this.sortSuggestions();
+      }
+
+      this.suggestions = this.suggestions.slice(0, this.options.maxItems);
+
+      this.suggestions.forEach((suggestion, index) => {
+        this.ul.appendChild(this.suggestionItem(suggestion, index));
+      });
+    }
+
+    if (this.ul.children.length === 0) {
+      this.close();
+    } else {
+      this.open();
+    }
+
+    window.clearTimeout(this.timeOutId);
+    // Delay the results announcement by 1400 milliseconds. This prevents
+    // unnecessary calls when a user is typing quickly, and avoids the results
+    // announcement being cut short by the screenreader stating the just-typed
+    // character.
+    this.timeOutId = setTimeout(
+      () => this.sendToLiveRegion(this.resultsMessage(this.ul.children.length)),
+      1400,
+    );
+  }
+
+  /**
+   * Sorts the array of suggestions.
+   */
+  sortSuggestions() {
+    this.suggestions.sort((prior, current) =>
+      prior.label.toUpperCase() > current.label.toUpperCase() ? 1 : -1,
+    );
+  }
+
+  /**
+   * Creates a list item that displays the suggestion.
+   *
+   * @param {object} suggestion
+   *   A suggestion based on user input. It is an object with label and value
+   *   properties.
+   * @param {number} itemIndex
+   *   The index of the item.
+   *
+   * @return {HTMLElement}
+   *   A list item with the suggestion.
+   */
+  suggestionItem(suggestion, itemIndex) {
+    const li = document.createElement('li');
+    li.innerHTML = this.formatSuggestionItem(suggestion);
+    if (this.options.itemClass.length > 0) {
+      this.options.itemClass
+        .split(' ')
+        .forEach((className) => li.classList.add(className));
+    }
+    li.setAttribute('role', 'option');
+    li.setAttribute('tabindex', '-1');
+    li.setAttribute('id', `suggestion-${this.count}-${itemIndex}`);
+    li.setAttribute('data-drupal-autocomplete-item', itemIndex);
+    li.setAttribute('aria-posinset', itemIndex + 1);
+    li.setAttribute('aria-selected', 'false');
+    li.onblur = (e) => this.blurHandler(e);
+
+    return li;
+  }
+
+  /**
+   * Formats how a suggestion is structured in the suggestion list.
+   *
+   * @param {object} suggestion
+   *   Object with value and label properties.
+   *
+   * @return {string}
+   *   The text and html of a suggestion item.
+   */
+  formatSuggestionItem(suggestion) {
+    const propertyToDisplay = this.options.displayLabels ? 'label' : 'value';
+    return suggestion[propertyToDisplay].trim();
+  }
+
+  /**
+   * Opens the suggestion list.
+   */
+  open() {
+    this.input.setAttribute('aria-expanded', 'true');
+    this.ul.removeAttribute('hidden');
+    this.ul.style.zIndex = this.options.listZindex;
+    this.isOpened = true;
+    this.ul.style.minWidth = `${this.input.offsetWidth - 4}px`;
+    this.triggerEvent('autocomplete-open');
+  }
+
+  /**
+   * Closes the suggestion list.
+   */
+  close() {
+    this.input.setAttribute('aria-expanded', 'false');
+    this.ul.setAttribute('hidden', '');
+    this.isOpened = false;
+    this.triggerEvent('autocomplete-close');
+  }
+
+  /**
+   * Returns the last value of an multi-value textfield.
+   *
+   * @return {string}
+   *   The last value of the input field.
+   */
+  extractLastInputValue() {
+    return this.splitValues().pop();
+  }
+
+  /**
+   * Helper splitting selections from the autocomplete value.
+   *
+   * @return {Array}
+   *   Array of values, split by comma.
+   */
+  splitValues() {
+    const { value } = this.input;
+    const result = [];
+    let quote = false;
+    let current = '';
+    const valueLength = value.length;
+    for (let i = 0; i < valueLength; i++) {
+      const character = value.charAt(i);
+      if (character === '"') {
+        current += character;
+        quote = !quote;
+      } else if (character === this.options.separatorChar && !quote) {
+        result.push(current.trim());
+        current = '';
+      } else {
+        current += character;
+      }
+    }
+    if (value.length > 0) {
+      result.push(current.trim());
+    }
+    return result;
+  }
+
+  /**
+   * Determines if a suggestion should be an available option.
+   *
+   * @param {object} suggestion
+   *   A suggestion based on user input. It is an object with label and value
+   *   properties.
+   * @param {string} typed
+   *   The text entered in the input field.
+   *
+   * @return {boolean}
+   *   If the suggestion should be displayed in the results.
+   */
+  filterResults(suggestion, typed) {
+    const { firstCharacterDenylist, cardinality } = this.options;
+    const suggestionValue = suggestion.value;
+    const currentValues = this.splitValues();
+
+    // Prevent suggestions if the first input character is in the denylist, if
+    // the suggestion has already been added to the field, or if the maximum
+    // number of items have been reached.
+    if (
+      firstCharacterDenylist.indexOf(typed[0]) !== -1 ||
+      currentValues.indexOf(suggestionValue) !== -1 ||
+      (cardinality > 0 && currentValues.length > cardinality)
+    ) {
+      return false;
+    }
+
+    return RegExp(
+      this.extractLastInputValue()
+        .trim()
+        .replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&'),
+      'i',
+    ).test(suggestionValue);
+  }
+
+  /**
+   * Announces number of suggestions found to assistive tech.
+   *
+   * @param {number} count
+   *   The number of suggestions.
+   */
+  announceResults(count) {
+    const message = this.resultsMessage(count);
+    this.sendToLiveRegion(message);
+  }
+
+  /**
+   * Sends a message to the configured live region.
+   *
+   * @param {string} message
+   *   The message to be sent to the live region.
+   */
+  sendToLiveRegion(message) {
+    if (this.liveRegion) {
+      this.liveRegion.textContent = message;
+    }
+  }
+
+  /**
+   * A message regarding the number of suggestions found.
+   *
+   * @param {number} count
+   *   The number of suggestions found.
+   *
+   * @return {string}
+   *   A message based on the number of suggestions found.
+   */
+  resultsMessage(count) {
+    const { maxItems } = this.options;
+    let message = '';
+    if (count === 0) {
+      message = this.options.messages.noResults;
+    } else if (maxItems === count) {
+      message = this.options.messages.moreThanMaxResults;
+    } else if (count === 1) {
+      message = this.options.messages.oneResult;
+    } else {
+      message = this.options.messages.someResults;
+    }
+    return message.replace('@count', count);
+  }
+
+  /**
+   * A message stating the number of characters needed to trigger autocomplete.
+   *
+   * @return {string}
+   *  The minimum characters message.
+   */
+  minCharsMessage() {
+    if (this.options.minChars > 1) {
+      return `${this.options.minCharAssistiveHint.replace(
+        '@count',
+        this.options.minChars,
+      )}. `;
+    }
+    return '';
+  }
+
+  /**
+   * Remove all event listeners added by this class.
+   */
+  destroy() {
+    if (window.jQuery) {
+      const $ = window.jQuery;
+      $(this.input).off(this.events.input);
+      $(this.ul).off(this.events.ul);
+    } else {
+      Object.keys(this.events).forEach((elementName) => {
+        Object.keys(this.events[elementName]).forEach((eventName) => {
+          this[elementName].removeEventListener(
+            eventName,
+            this.events[elementName][eventName],
+          );
+        });
+      });
+    }
+  }
+
+  /**
+   * Dispatches an autocomplete event
+   *
+   * @param {string} type
+   *   The event type.
+   * @param {object} additionalData
+   *   Additional data attached to the event's `details` property.
+   * @param {boolean} cancelable
+   *   If the dispatched event should be cancelable.
+   *
+   * @return {boolean}
+   *   If the event triggered successfully.
+   */
+  triggerEvent(type, additionalData = {}, cancelable = false) {
+    const event = new CustomEvent(type, {
+      detail: {
+        autocomplete: this,
+        ...additionalData,
+      },
+      cancelable,
+    });
+    return this.input.dispatchEvent(event);
+  }
+}
+
+window.DrupalAutocomplete = DrupalAutocomplete;
diff --git a/core/modules/drupal_autocomplete/js/drupalautocomplete.js b/core/modules/drupal_autocomplete/js/drupalautocomplete.js
new file mode 100644
index 0000000000..f099355fd0
--- /dev/null
+++ b/core/modules/drupal_autocomplete/js/drupalautocomplete.js
@@ -0,0 +1,669 @@
+/**
+* DO NOT EDIT THIS FILE.
+* See the following change record for more information,
+* https://www.drupal.org/node/2815083
+* @preserve
+**/
+
+function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; }
+
+function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; }
+
+function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
+
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }
+
+var DrupalAutocomplete = function () {
+  function DrupalAutocomplete(input) {
+    var _this = this;
+
+    var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
+
+    _classCallCheck(this, DrupalAutocomplete);
+
+    this.keyCode = Object.freeze({
+      TAB: 9,
+      RETURN: 13,
+      ESC: 27,
+      SPACE: 32,
+      PAGEUP: 33,
+      PAGEDOWN: 34,
+      END: 35,
+      HOME: 36,
+      LEFT: 37,
+      UP: 38,
+      RIGHT: 39,
+      DOWN: 40
+    });
+    this.input = input;
+    this.count = document.querySelectorAll('[data-drupal-autocomplete-input]').length;
+    this.listboxId = "autocomplete-listbox-".concat(this.count);
+    var defaultOptions = {
+      firstCharacterDenylist: ',',
+      minChars: 1,
+      maxItems: 10,
+      sort: false,
+      path: false,
+      displayLabels: true,
+      list: [],
+      cardinality: 1,
+      inputClass: '',
+      ulClass: '',
+      itemClass: '',
+      loadingClass: 'drupal-autocomplete-loading',
+      separatorChar: ',',
+      createLiveRegion: true,
+      listZindex: 100,
+      inputAssistiveHint: 'When autocomplete results are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures.',
+      minCharAssistiveHint: 'Type @count or more characters for results',
+      messages: {
+        noResults: 'No results found',
+        moreThanMaxResults: 'There are at least @count results available. Type additional characters to refine your search.',
+        someResults: 'There are @count results available.',
+        oneResult: 'There is one result available.'
+      }
+    };
+    this.options = _objectSpread(_objectSpread(_objectSpread({}, defaultOptions), options), this.attributesToOptions());
+
+    if (typeof this.options.list === 'string') {
+      this.options.list = JSON.parse(this.options.list);
+    }
+
+    this.preventCloseOnBlur = false;
+    this.isOpened = false;
+    this.cache = [];
+    this.suggestionItems = [];
+    this.hasAnnouncedOnce = false;
+    this.timeOutId = null;
+    this.wrapper = document.createElement('div');
+    this.implementWrapper();
+    this.inputDescribedBy = this.input.getAttribute('aria-describedby');
+    this.inputHintRead = false;
+    this.implementInput();
+    this.ul = document.createElement('ul');
+    this.implementList();
+    this.liveRegion = null;
+    this.implementLiveRegion();
+    this.events = {
+      input: {
+        input: function input() {
+          return _this.inputListener();
+        },
+        blur: function blur(e) {
+          return _this.blurHandler(e);
+        },
+        keydown: function keydown(e) {
+          return _this.inputKeyDown(e);
+        }
+      },
+      ul: {
+        mousedown: function mousedown(e) {
+          return e.preventDefault();
+        },
+        click: function click(e) {
+          return _this.itemClick(e);
+        },
+        keydown: function keydown(e) {
+          return _this.listKeyDown(e);
+        },
+        blur: function blur(e) {
+          return _this.blurHandler(e);
+        }
+      }
+    };
+
+    if (window.jQuery) {
+      var $ = window.jQuery;
+      $(this.input).on(this.events.input);
+      $(this.ul).on(this.events.ul);
+    } else {
+      Object.keys(this.events).forEach(function (elementName) {
+        Object.keys(_this.events[elementName]).forEach(function (eventName) {
+          _this[elementName].addEventListener(eventName, _this.events[elementName][eventName]);
+        });
+      });
+    }
+
+    this.triggerEvent('autocomplete-created');
+  }
+
+  _createClass(DrupalAutocomplete, [{
+    key: "implementWrapper",
+    value: function implementWrapper() {
+      this.wrapper.setAttribute('data-drupal-autocomplete-wrapper', '');
+      this.input.parentNode.appendChild(this.wrapper);
+      this.wrapper.appendChild(this.input);
+    }
+  }, {
+    key: "implementInput",
+    value: function implementInput() {
+      var _this2 = this;
+
+      this.input.setAttribute('aria-autocomplete', 'list');
+      this.input.setAttribute('autocomplete', 'off');
+      this.input.setAttribute('data-drupal-autocomplete-input', '');
+      this.input.setAttribute('aria-owns', this.listboxId);
+      this.input.setAttribute('role', 'combobox');
+      this.input.setAttribute('aria-expanded', 'false');
+
+      if (this.options.inputClass.length > 0) {
+        this.options.inputClass.split(' ').forEach(function (className) {
+          return _this2.input.classList.add(className);
+        });
+      }
+
+      if (!this.input.hasAttribute('id')) {
+        this.input.setAttribute("autocomplete-input-".concat(this.count));
+      }
+
+      var description = document.createElement('span');
+      description.textContent = this.minCharsMessage() + this.options.inputAssistiveHint;
+      description.classList.add('visually-hidden');
+
+      if (this.inputDescribedBy) {
+        description.setAttribute('data-drupal-autocomplete-assistive-hint', this.count);
+        document.querySelector("[id=\"".concat(this.inputDescribedBy, "\"]")).appendChild(description);
+      } else {
+        description.setAttribute('id', "assistive-hint-".concat(this.count));
+        this.input.setAttribute('aria-describedby', "assistive-hint-".concat(this.count));
+        this.wrapper.appendChild(description);
+      }
+    }
+  }, {
+    key: "implementList",
+    value: function implementList() {
+      var _this3 = this;
+
+      this.ul.setAttribute('role', 'listbox');
+      this.ul.setAttribute('data-drupal-autocomplete-list', '');
+      this.ul.setAttribute('id', this.listboxId);
+      this.ul.setAttribute('hidden', '');
+      this.input.parentNode.appendChild(this.ul);
+
+      if (this.options.ulClass.length > 0) {
+        this.options.ulClass.split(' ').forEach(function (className) {
+          return _this3.ul.classList.add(className);
+        });
+      }
+    }
+  }, {
+    key: "implementLiveRegion",
+    value: function implementLiveRegion() {
+      if (this.options.createLiveRegion === true) {
+        this.liveRegion = document.createElement('span');
+        this.liveRegion.setAttribute('data-drupal-autocomplete-live-region', '');
+        this.liveRegion.setAttribute('aria-live', 'assertive');
+        this.input.parentNode.appendChild(this.liveRegion);
+      }
+
+      if (typeof this.options.liveRegion === 'string') {
+        this.liveRegion = document.querySelector(this.options.liveRegion);
+      }
+    }
+  }, {
+    key: "attributesToOptions",
+    value: function attributesToOptions() {
+      var options = {};
+      var dataAutocompleteAttributeOptions = this.input.getAttribute('data-autocomplete') ? JSON.parse(this.input.getAttribute('data-autocomplete')) : {};
+
+      for (var i = 0; i < this.input.attributes.length; i++) {
+        if (this.input.attributes[i].nodeName.includes('data-autocomplete') && this.input.attributes[i].nodeName !== 'data-autocomplete') {
+          var optionName = this.input.attributes[i].nodeName.replace('data-autocomplete-', '').split('-').map(function (w) {
+            return w.charAt(0).toUpperCase() + w.slice(1);
+          }).join('');
+          optionName = optionName.charAt(0).toLowerCase() + optionName.slice(1);
+          options[optionName] = this.input.attributes[i].nodeValue;
+        }
+      }
+
+      return _objectSpread(_objectSpread({}, options), dataAutocompleteAttributeOptions);
+    }
+  }, {
+    key: "blurHandler",
+    value: function blurHandler(e) {
+      window.clearTimeout(this.timeOutId);
+
+      if (this.preventCloseOnBlur) {
+        this.preventCloseOnBlur = false;
+        e.preventDefault();
+      } else {
+        this.close();
+      }
+    }
+  }, {
+    key: "removeAssistiveHint",
+    value: function removeAssistiveHint() {
+      if (!this.inputHintRead) {
+        if (this.inputDescribedBy) {
+          var appendedHint = document.querySelector("[data-drupal-autocomplete-assistive-hint=\"".concat(this.count, "\"]"));
+          appendedHint.parentNode.removeChild(appendedHint);
+        } else {
+          this.input.removeAttribute('aria-describedby');
+        }
+
+        this.inputHintRead = true;
+      }
+    }
+  }, {
+    key: "listKeyDown",
+    value: function listKeyDown(e) {
+      if (!this.ul.contains(document.activeElement) || e.ctrlKey || e.altKey || e.metaKey || e.keyCode === this.keyCode.TAB) {
+        return;
+      }
+
+      this.ul.querySelectorAll('[aria-selected="true"]').forEach(function (li) {
+        li.setAttribute('aria-selected', 'false');
+      });
+
+      switch (e.keyCode) {
+        case this.keyCode.SPACE:
+        case this.keyCode.RETURN:
+          this.replaceInputValue(document.activeElement.textContent);
+          this.close();
+          this.input.focus();
+          break;
+
+        case this.keyCode.ESC:
+        case this.keyCode.TAB:
+          this.input.focus();
+          this.close();
+          break;
+
+        case this.keyCode.UP:
+          this.focusPrev();
+          break;
+
+        case this.keyCode.DOWN:
+          this.focusNext();
+          break;
+
+        default:
+          break;
+      }
+
+      e.stopPropagation();
+      e.preventDefault();
+    }
+  }, {
+    key: "focusPrev",
+    value: function focusPrev() {
+      this.preventCloseOnBlur = true;
+      var currentItem = document.activeElement.getAttribute('data-drupal-autocomplete-item');
+      var prevIndex = parseInt(currentItem, 10) - 1;
+      var previousItem = this.ul.querySelector("[data-drupal-autocomplete-item=\"".concat(prevIndex, "\"]"));
+
+      if (previousItem) {
+        this.highlightItem(previousItem);
+      } else {
+        this.input.focus();
+      }
+    }
+  }, {
+    key: "focusNext",
+    value: function focusNext() {
+      var currentItem = document.activeElement.getAttribute('data-drupal-autocomplete-item');
+      var nextIndex = parseInt(currentItem, 10) + 1;
+      var nextItem = this.ul.querySelector("[data-drupal-autocomplete-item=\"".concat(nextIndex, "\"]"));
+
+      if (nextItem) {
+        this.preventCloseOnBlur = true;
+        this.highlightItem(nextItem);
+      }
+    }
+  }, {
+    key: "highlightItem",
+    value: function highlightItem(item) {
+      item.setAttribute('aria-selected', true);
+      item.focus();
+      this.announceHighlight(item);
+    }
+  }, {
+    key: "announceHighlight",
+    value: function announceHighlight(item) {
+      var _this4 = this;
+
+      window.clearTimeout(this.timeOutId);
+      this.timeOutId = setTimeout(function () {
+        return _this4.sendToLiveRegion(_this4.highlightMessage(item));
+      }, 500);
+    }
+  }, {
+    key: "highlightMessage",
+    value: function highlightMessage(item) {
+      return "".concat(item.innerText, " ").concat(item.getAttribute('aria-posinset'), " of ").concat(this.ul.children.length, " is highlighted");
+    }
+  }, {
+    key: "inputKeyDown",
+    value: function inputKeyDown(e) {
+      var keyCode = e.keyCode;
+
+      if (this.isOpened) {
+        if (keyCode === this.keyCode.ESC) {
+          this.close();
+        }
+
+        if (keyCode === this.keyCode.DOWN) {
+          e.preventDefault();
+          this.preventCloseOnBlur = true;
+          this.highlightItem(this.ul.querySelector('li'));
+        }
+      }
+
+      this.removeAssistiveHint();
+    }
+  }, {
+    key: "itemClick",
+    value: function itemClick(e) {
+      var li = e.target;
+
+      if (li && e.button === 0) {
+        var selected = this.triggerEvent('autocomplete-select', {
+          originalEvent: e,
+          selected: li.textContent
+        }, true);
+
+        if (selected) {
+          this.replaceInputValue(li.textContent);
+          e.preventDefault();
+          this.close();
+          this.triggerEvent('autocomplete-selection-added', {
+            added: li.textContent
+          });
+        }
+      }
+    }
+  }, {
+    key: "replaceInputValue",
+    value: function replaceInputValue(item) {
+      var separator = this.separator();
+
+      if (separator.length > 0) {
+        var before = this.previousItems(separator);
+        this.input.value = "".concat(before).concat(item);
+      } else {
+        this.input.value = item;
+      }
+    }
+  }, {
+    key: "separator",
+    value: function separator() {
+      var cardinality = this.options.cardinality;
+      var numItems = this.splitValues().length - 1;
+      return numItems < cardinality || parseInt(cardinality, 10) <= 0 ? this.options.separatorChar : '';
+    }
+  }, {
+    key: "previousItems",
+    value: function previousItems(separator) {
+      var escapedSeparator = separator.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+      var regex = new RegExp("^.+".concat(escapedSeparator, "\\s*|"));
+      var match = this.input.value.match(regex)[0];
+      return match && match.length > 0 ? "".concat(match.trim(), " ") : '';
+    }
+  }, {
+    key: "inputListener",
+    value: function inputListener() {
+      var _this5 = this;
+
+      var inputId = this.input.getAttribute('id');
+      var searchTerm = this.extractLastInputValue();
+
+      if (!(inputId in this.cache)) {
+        this.cache[inputId] = {};
+      }
+
+      if (searchTerm && searchTerm.length > 0) {
+        if (this.cache[inputId].hasOwnProperty(searchTerm)) {
+          this.suggestionItems = this.cache[inputId][searchTerm];
+          this.displayResults();
+        } else if (this.options.list.length === 0 && this.options.path) {
+          this.options.loadingClass.split(' ').forEach(function (className) {
+            return _this5.input.classList.add(className);
+          });
+          fetch(this.queryUrl(searchTerm)).then(function (response) {
+            return response.json();
+          }).then(function (results) {
+            _this5.options.loadingClass.split(' ').forEach(function (className) {
+              return _this5.input.classList.remove(className);
+            });
+
+            _this5.suggestionItems = results;
+
+            _this5.displayResults();
+
+            _this5.cache[inputId][searchTerm] = results;
+          });
+        } else {
+          this.suggestionItems = this.options.list;
+          this.displayResults();
+        }
+      } else {
+        this.suggestionItems = [];
+        this.displayResults();
+      }
+    }
+  }, {
+    key: "queryUrl",
+    value: function queryUrl(searchTerm) {
+      return "".concat(this.options.path, "?q=").concat(searchTerm);
+    }
+  }, {
+    key: "displayResults",
+    value: function displayResults() {
+      var _this6 = this;
+
+      var typed = this.extractLastInputValue();
+      this.ul.innerHTML = '';
+
+      if (typed && typed.length >= this.options.minChars && this.suggestionItems.length > 0) {
+        this.suggestions = this.suggestionItems.filter(function (item) {
+          return _this6.filterResults(item, typed);
+        });
+
+        if (this.options.sort !== false) {
+          this.sortSuggestions();
+        }
+
+        this.suggestions = this.suggestions.slice(0, this.options.maxItems);
+        this.suggestions.forEach(function (suggestion, index) {
+          _this6.ul.appendChild(_this6.suggestionItem(suggestion, index));
+        });
+      }
+
+      if (this.ul.children.length === 0) {
+        this.close();
+      } else {
+        this.open();
+      }
+
+      window.clearTimeout(this.timeOutId);
+      this.timeOutId = setTimeout(function () {
+        return _this6.sendToLiveRegion(_this6.resultsMessage(_this6.ul.children.length));
+      }, 1400);
+    }
+  }, {
+    key: "sortSuggestions",
+    value: function sortSuggestions() {
+      this.suggestions.sort(function (prior, current) {
+        return prior.label.toUpperCase() > current.label.toUpperCase() ? 1 : -1;
+      });
+    }
+  }, {
+    key: "suggestionItem",
+    value: function suggestionItem(suggestion, itemIndex) {
+      var _this7 = this;
+
+      var li = document.createElement('li');
+      li.innerHTML = this.formatSuggestionItem(suggestion);
+
+      if (this.options.itemClass.length > 0) {
+        this.options.itemClass.split(' ').forEach(function (className) {
+          return li.classList.add(className);
+        });
+      }
+
+      li.setAttribute('role', 'option');
+      li.setAttribute('tabindex', '-1');
+      li.setAttribute('id', "suggestion-".concat(this.count, "-").concat(itemIndex));
+      li.setAttribute('data-drupal-autocomplete-item', itemIndex);
+      li.setAttribute('aria-posinset', itemIndex + 1);
+      li.setAttribute('aria-selected', 'false');
+
+      li.onblur = function (e) {
+        return _this7.blurHandler(e);
+      };
+
+      return li;
+    }
+  }, {
+    key: "formatSuggestionItem",
+    value: function formatSuggestionItem(suggestion) {
+      var propertyToDisplay = this.options.displayLabels ? 'label' : 'value';
+      return suggestion[propertyToDisplay].trim();
+    }
+  }, {
+    key: "open",
+    value: function open() {
+      this.input.setAttribute('aria-expanded', 'true');
+      this.ul.removeAttribute('hidden');
+      this.ul.style.zIndex = this.options.listZindex;
+      this.isOpened = true;
+      this.ul.style.minWidth = "".concat(this.input.offsetWidth - 4, "px");
+      this.triggerEvent('autocomplete-open');
+    }
+  }, {
+    key: "close",
+    value: function close() {
+      this.input.setAttribute('aria-expanded', 'false');
+      this.ul.setAttribute('hidden', '');
+      this.isOpened = false;
+      this.triggerEvent('autocomplete-close');
+    }
+  }, {
+    key: "extractLastInputValue",
+    value: function extractLastInputValue() {
+      return this.splitValues().pop();
+    }
+  }, {
+    key: "splitValues",
+    value: function splitValues() {
+      var value = this.input.value;
+      var result = [];
+      var quote = false;
+      var current = '';
+      var valueLength = value.length;
+
+      for (var i = 0; i < valueLength; i++) {
+        var character = value.charAt(i);
+
+        if (character === '"') {
+          current += character;
+          quote = !quote;
+        } else if (character === this.options.separatorChar && !quote) {
+          result.push(current.trim());
+          current = '';
+        } else {
+          current += character;
+        }
+      }
+
+      if (value.length > 0) {
+        result.push(current.trim());
+      }
+
+      return result;
+    }
+  }, {
+    key: "filterResults",
+    value: function filterResults(suggestion, typed) {
+      var _this$options = this.options,
+          firstCharacterDenylist = _this$options.firstCharacterDenylist,
+          cardinality = _this$options.cardinality;
+      var suggestionValue = suggestion.value;
+      var currentValues = this.splitValues();
+
+      if (firstCharacterDenylist.indexOf(typed[0]) !== -1 || currentValues.indexOf(suggestionValue) !== -1 || cardinality > 0 && currentValues.length > cardinality) {
+        return false;
+      }
+
+      return RegExp(this.extractLastInputValue().trim().replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&'), 'i').test(suggestionValue);
+    }
+  }, {
+    key: "announceResults",
+    value: function announceResults(count) {
+      var message = this.resultsMessage(count);
+      this.sendToLiveRegion(message);
+    }
+  }, {
+    key: "sendToLiveRegion",
+    value: function sendToLiveRegion(message) {
+      if (this.liveRegion) {
+        this.liveRegion.textContent = message;
+      }
+    }
+  }, {
+    key: "resultsMessage",
+    value: function resultsMessage(count) {
+      var maxItems = this.options.maxItems;
+      var message = '';
+
+      if (count === 0) {
+        message = this.options.messages.noResults;
+      } else if (maxItems === count) {
+        message = this.options.messages.moreThanMaxResults;
+      } else if (count === 1) {
+        message = this.options.messages.oneResult;
+      } else {
+        message = this.options.messages.someResults;
+      }
+
+      return message.replace('@count', count);
+    }
+  }, {
+    key: "minCharsMessage",
+    value: function minCharsMessage() {
+      if (this.options.minChars > 1) {
+        return "".concat(this.options.minCharAssistiveHint.replace('@count', this.options.minChars), ". ");
+      }
+
+      return '';
+    }
+  }, {
+    key: "destroy",
+    value: function destroy() {
+      var _this8 = this;
+
+      if (window.jQuery) {
+        var $ = window.jQuery;
+        $(this.input).off(this.events.input);
+        $(this.ul).off(this.events.ul);
+      } else {
+        Object.keys(this.events).forEach(function (elementName) {
+          Object.keys(_this8.events[elementName]).forEach(function (eventName) {
+            _this8[elementName].removeEventListener(eventName, _this8.events[elementName][eventName]);
+          });
+        });
+      }
+    }
+  }, {
+    key: "triggerEvent",
+    value: function triggerEvent(type) {
+      var additionalData = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
+      var cancelable = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : false;
+      var event = new CustomEvent(type, {
+        detail: _objectSpread({
+          autocomplete: this
+        }, additionalData),
+        cancelable: cancelable
+      });
+      return this.input.dispatchEvent(event);
+    }
+  }]);
+
+  return DrupalAutocomplete;
+}();
+
+window.DrupalAutocomplete = DrupalAutocomplete;
\ No newline at end of file
diff --git a/core/modules/drupal_autocomplete/tests/modules/drupal_autocomplete_test/drupal_autocomplete_test.info.yml b/core/modules/drupal_autocomplete/tests/modules/drupal_autocomplete_test/drupal_autocomplete_test.info.yml
new file mode 100644
index 0000000000..7483f028b9
--- /dev/null
+++ b/core/modules/drupal_autocomplete/tests/modules/drupal_autocomplete_test/drupal_autocomplete_test.info.yml
@@ -0,0 +1,7 @@
+name: 'Drupal Autocomplete Test'
+type: module
+description: 'Provides forms to test the Drupal Autocomplete module'
+package: Testing
+version: VERSION
+dependencies:
+  - drupal:form_test
diff --git a/core/modules/drupal_autocomplete/tests/modules/drupal_autocomplete_test/drupal_autocomplete_test.routing.yml b/core/modules/drupal_autocomplete/tests/modules/drupal_autocomplete_test/drupal_autocomplete_test.routing.yml
new file mode 100644
index 0000000000..0acb71629f
--- /dev/null
+++ b/core/modules/drupal_autocomplete/tests/modules/drupal_autocomplete_test/drupal_autocomplete_test.routing.yml
@@ -0,0 +1,15 @@
+drupal_autocomplete.test.form:
+  path: '/drupal_autocomplete/test-form'
+  defaults:
+    _form: '\Drupal\drupal_autocomplete_test\Form\AutocompleteTestForm'
+    _title: 'Autocomplete test form'
+  requirements:
+    _permission: 'access content'
+
+drupal_autocomplete.country_autocomplete:
+  path: '/drupal_autocomplete_countries'
+  defaults:
+    _controller: '\Drupal\drupal_autocomplete_test\Controller\AutocompleteController::autocomplete'
+  requirements:
+    # Explicitly allow access to country autocomplete.
+    _access: 'TRUE'
diff --git a/core/modules/drupal_autocomplete/tests/modules/drupal_autocomplete_test/src/Controller/AutocompleteController.php b/core/modules/drupal_autocomplete/tests/modules/drupal_autocomplete_test/src/Controller/AutocompleteController.php
new file mode 100644
index 0000000000..1c2449d4c4
--- /dev/null
+++ b/core/modules/drupal_autocomplete/tests/modules/drupal_autocomplete_test/src/Controller/AutocompleteController.php
@@ -0,0 +1,333 @@
+<?php
+
+namespace Drupal\drupal_autocomplete_test\Controller;
+
+use Drupal\Core\Controller\ControllerBase;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Defines a route controller for entity autocomplete form elements.
+ */
+class AutocompleteController extends ControllerBase {
+
+  /**
+   * Get an array of all two-letter country code => country name pairs.
+   *
+   * This is copied from \Drupal\Core\Locale\CountryManager::getStandardList.
+   * A copy is used instead of calling CountryManger to ensure that real world
+   * changes to country names do not disrupt these tests.
+   *
+   * @return array
+   *   An array of country code => country name pairs.
+   */
+  public function getCountryList() {
+    // cSpell:disable
+    $countries = [
+      'AC' => t('Ascension Island'),
+      'AD' => t('Andorra'),
+      'AE' => t('United Arab Emirates'),
+      'AF' => t('Afghanistan'),
+      'AG' => t('Antigua & Barbuda'),
+      'AI' => t('Anguilla'),
+      'AL' => t('Albania'),
+      'AM' => t('Armenia'),
+      'AN' => t('Netherlands Antilles'),
+      'AO' => t('Angola'),
+      'AQ' => t('Antarctica'),
+      'AR' => t('Argentina'),
+      'AS' => t('American Samoa'),
+      'AT' => t('Austria'),
+      'AU' => t('Australia'),
+      'AW' => t('Aruba'),
+      'AX' => t('Åland Islands'),
+      'AZ' => t('Azerbaijan'),
+      'BA' => t('Bosnia & Herzegovina'),
+      'BB' => t('Barbados'),
+      'BD' => t('Bangladesh'),
+      'BE' => t('Belgium'),
+      'BF' => t('Burkina Faso'),
+      'BG' => t('Bulgaria'),
+      'BH' => t('Bahrain'),
+      'BI' => t('Burundi'),
+      'BJ' => t('Benin'),
+      'BL' => t('St. Barthélemy'),
+      'BM' => t('Bermuda'),
+      'BN' => t('Brunei'),
+      'BO' => t('Bolivia'),
+      'BQ' => t('Caribbean Netherlands'),
+      'BR' => t('Brazil'),
+      'BS' => t('Bahamas'),
+      'BT' => t('Bhutan'),
+      'BV' => t('Bouvet Island'),
+      'BW' => t('Botswana'),
+      'BY' => t('Belarus'),
+      'BZ' => t('Belize'),
+      'CA' => t('Canada'),
+      'CC' => t('Cocos (Keeling) Islands'),
+      'CD' => t('Congo - Kinshasa'),
+      'CF' => t('Central African Republic'),
+      'CG' => t('Congo - Brazzaville'),
+      'CH' => t('Switzerland'),
+      'CI' => t('Côte d’Ivoire'),
+      'CK' => t('Cook Islands'),
+      'CL' => t('Chile'),
+      'CM' => t('Cameroon'),
+      'CN' => t('China'),
+      'CO' => t('Colombia'),
+      'CP' => t('Clipperton Island'),
+      'CR' => t('Costa Rica'),
+      'CU' => t('Cuba'),
+      'CV' => t('Cape Verde'),
+      'CW' => t('Curaçao'),
+      'CX' => t('Christmas Island'),
+      'CY' => t('Cyprus'),
+      'CZ' => t('Czechia'),
+      'DE' => t('Germany'),
+      'DG' => t('Diego Garcia'),
+      'DJ' => t('Djibouti'),
+      'DK' => t('Denmark'),
+      'DM' => t('Dominica'),
+      'DO' => t('Dominican Republic'),
+      'DZ' => t('Algeria'),
+      'EA' => t('Ceuta & Melilla'),
+      'EC' => t('Ecuador'),
+      'EE' => t('Estonia'),
+      'EG' => t('Egypt'),
+      'EH' => t('Western Sahara'),
+      'ER' => t('Eritrea'),
+      'ES' => t('Spain'),
+      'ET' => t('Ethiopia'),
+      'FI' => t('Finland'),
+      'FJ' => t('Fiji'),
+      'FK' => t('Falkland Islands'),
+      'FM' => t('Micronesia'),
+      'FO' => t('Faroe Islands'),
+      'FR' => t('France'),
+      'GA' => t('Gabon'),
+      'GB' => t('United Kingdom'),
+      'GD' => t('Grenada'),
+      'GE' => t('Georgia'),
+      'GF' => t('French Guiana'),
+      'GG' => t('Guernsey'),
+      'GH' => t('Ghana'),
+      'GI' => t('Gibraltar'),
+      'GL' => t('Greenland'),
+      'GM' => t('Gambia'),
+      'GN' => t('Guinea'),
+      'GP' => t('Guadeloupe'),
+      'GQ' => t('Equatorial Guinea'),
+      'GR' => t('Greece'),
+      'GS' => t('South Georgia & South Sandwich Islands'),
+      'GT' => t('Guatemala'),
+      'GU' => t('Guam'),
+      'GW' => t('Guinea-Bissau'),
+      'GY' => t('Guyana'),
+      'HK' => t('Hong Kong SAR China'),
+      'HM' => t('Heard & McDonald Islands'),
+      'HN' => t('Honduras'),
+      'HR' => t('Croatia'),
+      'HT' => t('Haiti'),
+      'HU' => t('Hungary'),
+      'IC' => t('Canary Islands'),
+      'ID' => t('Indonesia'),
+      'IE' => t('Ireland'),
+      'IL' => t('Israel'),
+      'IM' => t('Isle of Man'),
+      'IN' => t('India'),
+      'IO' => t('British Indian Ocean Territory'),
+      'IQ' => t('Iraq'),
+      'IR' => t('Iran'),
+      'IS' => t('Iceland'),
+      'IT' => t('Italy'),
+      'JE' => t('Jersey'),
+      'JM' => t('Jamaica'),
+      'JO' => t('Jordan'),
+      'JP' => t('Japan'),
+      'KE' => t('Kenya'),
+      'KG' => t('Kyrgyzstan'),
+      'KH' => t('Cambodia'),
+      'KI' => t('Kiribati'),
+      'KM' => t('Comoros'),
+      'KN' => t('St. Kitts & Nevis'),
+      'KP' => t('North Korea'),
+      'KR' => t('South Korea'),
+      'KW' => t('Kuwait'),
+      'KY' => t('Cayman Islands'),
+      'KZ' => t('Kazakhstan'),
+      'LA' => t('Laos'),
+      'LB' => t('Lebanon'),
+      'LC' => t('St. Lucia'),
+      'LI' => t('Liechtenstein'),
+      'LK' => t('Sri Lanka'),
+      'LR' => t('Liberia'),
+      'LS' => t('Lesotho'),
+      'LT' => t('Lithuania'),
+      'LU' => t('Luxembourg'),
+      'LV' => t('Latvia'),
+      'LY' => t('Libya'),
+      'MA' => t('Morocco'),
+      'MC' => t('Monaco'),
+      'MD' => t('Moldova'),
+      'ME' => t('Montenegro'),
+      'MF' => t('St. Martin'),
+      'MG' => t('Madagascar'),
+      'MH' => t('Marshall Islands'),
+      'MK' => t('North Macedonia'),
+      'ML' => t('Mali'),
+      'MM' => t('Myanmar (Burma)'),
+      'MN' => t('Mongolia'),
+      'MO' => t('Macao SAR China'),
+      'MP' => t('Northern Mariana Islands'),
+      'MQ' => t('Martinique'),
+      'MR' => t('Mauritania'),
+      'MS' => t('Montserrat'),
+      'MT' => t('Malta'),
+      'MU' => t('Mauritius'),
+      'MV' => t('Maldives'),
+      'MW' => t('Malawi'),
+      'MX' => t('Mexico'),
+      'MY' => t('Malaysia'),
+      'MZ' => t('Mozambique'),
+      'NA' => t('Namibia'),
+      'NC' => t('New Caledonia'),
+      'NE' => t('Niger'),
+      'NF' => t('Norfolk Island'),
+      'NG' => t('Nigeria'),
+      'NI' => t('Nicaragua'),
+      'NL' => t('Netherlands'),
+      'NO' => t('Norway'),
+      'NP' => t('Nepal'),
+      'NR' => t('Nauru'),
+      'NU' => t('Niue'),
+      'NZ' => t('New Zealand'),
+      'OM' => t('Oman'),
+      'PA' => t('Panama'),
+      'PE' => t('Peru'),
+      'PF' => t('French Polynesia'),
+      'PG' => t('Papua New Guinea'),
+      'PH' => t('Philippines'),
+      'PK' => t('Pakistan'),
+      'PL' => t('Poland'),
+      'PM' => t('St. Pierre & Miquelon'),
+      'PN' => t('Pitcairn Islands'),
+      'PR' => t('Puerto Rico'),
+      'PS' => t('Palestinian Territories'),
+      'PT' => t('Portugal'),
+      'PW' => t('Palau'),
+      'PY' => t('Paraguay'),
+      'QA' => t('Qatar'),
+      'QO' => t('Outlying Oceania'),
+      'RE' => t('Réunion'),
+      'RO' => t('Romania'),
+      'RS' => t('Serbia'),
+      'RU' => t('Russia'),
+      'RW' => t('Rwanda'),
+      'SA' => t('Saudi Arabia'),
+      'SB' => t('Solomon Islands'),
+      'SC' => t('Seychelles'),
+      'SD' => t('Sudan'),
+      'SE' => t('Sweden'),
+      'SG' => t('Singapore'),
+      'SH' => t('St. Helena'),
+      'SI' => t('Slovenia'),
+      'SJ' => t('Svalbard & Jan Mayen'),
+      'SK' => t('Slovakia'),
+      'SL' => t('Sierra Leone'),
+      'SM' => t('San Marino'),
+      'SN' => t('Senegal'),
+      'SO' => t('Somalia'),
+      'SR' => t('Suriname'),
+      'SS' => t('South Sudan'),
+      'ST' => t('São Tomé & Príncipe'),
+      'SV' => t('El Salvador'),
+      'SX' => t('Sint Maarten'),
+      'SY' => t('Syria'),
+      'SZ' => t('Eswatini'),
+      'TA' => t('Tristan da Cunha'),
+      'TC' => t('Turks & Caicos Islands'),
+      'TD' => t('Chad'),
+      'TF' => t('French Southern Territories'),
+      'TG' => t('Togo'),
+      'TH' => t('Thailand'),
+      'TJ' => t('Tajikistan'),
+      'TK' => t('Tokelau'),
+      'TL' => t('Timor-Leste'),
+      'TM' => t('Turkmenistan'),
+      'TN' => t('Tunisia'),
+      'TO' => t('Tonga'),
+      'TR' => t('Turkey'),
+      'TT' => t('Trinidad & Tobago'),
+      'TV' => t('Tuvalu'),
+      'TW' => t('Taiwan'),
+      'TZ' => t('Tanzania'),
+      'UA' => t('Ukraine'),
+      'UG' => t('Uganda'),
+      'UM' => t('U.S. Outlying Islands'),
+      'US' => t('United States'),
+      'UY' => t('Uruguay'),
+      'UZ' => t('Uzbekistan'),
+      'VA' => t('Vatican City'),
+      'VC' => t('St. Vincent & Grenadines'),
+      'VE' => t('Venezuela'),
+      'VG' => t('British Virgin Islands'),
+      'VI' => t('U.S. Virgin Islands'),
+      'VN' => t('Vietnam'),
+      'VU' => t('Vanuatu'),
+      'WF' => t('Wallis & Futuna'),
+      'WS' => t('Samoa'),
+      'XK' => t('Kosovo'),
+      'YE' => t('Yemen'),
+      'YT' => t('Mayotte'),
+      'ZA' => t('South Africa'),
+      'ZM' => t('Zambia'),
+      'ZW' => t('Zimbabwe'),
+    ];
+    // cSpell:enable
+    natcasesort($countries);
+
+    return $countries;
+  }
+
+  /**
+   * Autocomplete the name of a country.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request object that contains the typed text.
+   *
+   * @return \Symfony\Component\HttpFoundation\JsonResponse
+   *   The matched country names.
+   *
+   * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException
+   *   Thrown if the selection settings key is not found in the key/value store
+   *   or if it does not match the stored data.
+   */
+  public function autocomplete(Request $request) {
+    $limit = 20;
+    $matches = [];
+    // Get the typed string from the URL, if it exists.
+    if ($input = $request->query->get('q')) {
+      $countries = $this->getCountryList();
+      foreach ($countries as $country_code => $country_name) {
+        $country_name = (string) $country_name;
+
+        if (
+          (stripos($country_code, $input) !== 0) &&
+          (mb_stripos($country_name, $input) !== 0)
+        ) {
+          continue;
+        }
+        $matches[] = [
+          'value' => implode(' ', [$country_name, '(' . $country_code . ')']),
+          'label' => implode(' ', [$country_name, '(' . $country_code . ')']),
+        ];
+        if (count($matches) === $limit) {
+          break 1;
+        }
+      }
+    }
+
+    return new JsonResponse($matches);
+  }
+
+}
diff --git a/core/modules/drupal_autocomplete/tests/modules/drupal_autocomplete_test/src/Form/AutocompleteTestForm.php b/core/modules/drupal_autocomplete/tests/modules/drupal_autocomplete_test/src/Form/AutocompleteTestForm.php
new file mode 100644
index 0000000000..10e483a290
--- /dev/null
+++ b/core/modules/drupal_autocomplete/tests/modules/drupal_autocomplete_test/src/Form/AutocompleteTestForm.php
@@ -0,0 +1,259 @@
+<?php
+
+namespace Drupal\drupal_autocomplete_test\Form;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Provides a form for testing autocomplete options.
+ */
+class AutocompleteTestForm extends FormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'drupal_autocomplete_test_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * This form tests the various options that can be used to configure an
+   * instance of the DrupalAutocomplete JavaScript class. Options can be set
+   * directly on an element in two ways:
+   * - Using a data-autocomplete-(dash separated option name) attribute.
+   *   ex: data-autocomplete-min-chars="2"
+   * - The data-autocomplete attribute has a JSON string with all custom
+   *   options. The option properties are camel cased.
+   *   ex: data-autocomplete="{"minChars": 2}"
+   * Every option tested via this form has a version implemented via
+   * data-autocomplete={option: value} and another version implemented via
+   * data-autocomplete-(dash separated option name).
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    // Inputs with the minimum characters option.
+    $form['two_minchar_data_autocomplete'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Minimum 2 Characters data-autocomplete'),
+      '#default_value' => '',
+      '#description' => $this->t('This also tests appending minchar screenreader hints to descriptions'),
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete' => JSON::encode([
+          'minChars' => 2,
+        ]),
+      ],
+    ];
+    $form['two_minchar_separate_data_attributes'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Minimum 2 Characters data-min-char'),
+      '#default_value' => '',
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete-min-chars' => 2,
+      ],
+    ];
+
+    // Inputs with the first character denylist option.
+    $form['denylist_data_autocomplete'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Denylist "u" data-autocomplete'),
+      '#default_value' => '',
+      '#description' => $this->t('This also tests appending default screenreader hints to descriptions'),
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete' => JSON::encode([
+          'firstCharacterDenylist' => 'u',
+        ]),
+      ],
+    ];
+    $form['denylist_separate_data_attributes'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Denylist "u" separate data attributes'),
+      '#default_value' => '',
+      '#description' => $this->t('This also tests appending default screenreader hints to descriptions'),
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete-first-character-denylist' => 'u',
+      ],
+    ];
+
+    // Inputs that use options to add custom classes.
+    $form['custom_classes_data_autocomplete'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Custom classes data-autocomplete'),
+      '#default_value' => '',
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete' => JSON::encode([
+          'inputClass' => 'class-added-to-input another-class-added-to-input',
+          'ulClass' => 'class-added-to-ul another-class-added-to-ul',
+          'itemClass' => 'class-added-to-item another-class-added-to-item',
+        ]),
+      ],
+    ];
+    $form['custom_classes_separate_data_attributes'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Custom classes separate data attributes'),
+      '#default_value' => '',
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete-input-class' => 'class-added-to-input another-class-added-to-input',
+        'data-autocomplete-ul-class' => 'class-added-to-ul another-class-added-to-ul',
+        'data-autocomplete-item-class' => 'class-added-to-item another-class-added-to-item',
+      ],
+    ];
+
+    // Inputs with set cardinality and a custom separator.
+    $form['cardinality_separator_data_autocomplete'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('2 Cardinality data-autocomplete'),
+      '#default_value' => '',
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete' => JSON::encode([
+          'cardinality' => '2',
+          'separatorChar' => '|',
+          'firstCharacterDenylist' => '|',
+        ]),
+      ],
+    ];
+    $form['cardinality_separator_separate_data_attributes'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('2 Cardinality separate data attributes'),
+      '#default_value' => '',
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete' => JSON::encode([
+          'cardinality' => '2',
+          'separatorChar' => '|',
+          'firstCharacterDenylist' => '|',
+        ]),
+      ],
+    ];
+
+    // Inputs with custom max options instead of the default 10.
+    $form['maxitems_data_autocomplete'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('20 Max items data-autocomplete'),
+      '#default_value' => '',
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete' => JSON::encode([
+          'maxItems' => '20',
+        ]),
+      ],
+    ];
+    $form['maxitems_separate_data_attributes'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('20 Max items separate data attributes'),
+      '#default_value' => '',
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete-max-items' => '20',
+      ],
+    ];
+
+    $custom_list = [
+      [
+        'label' => 'Zebra Label',
+        'value' => 'Zebra Value',
+      ],
+      [
+        'label' => 'Rhino Label',
+        'value' => 'Rhino Value',
+      ],
+      [
+        'label' => 'Cheetah Label',
+        'value' => 'Cheetah Value',
+      ],
+      [
+        'label' => 'Meerkat Label',
+        'value' => 'Meerkat Value',
+      ],
+    ];
+
+    // Inputs with a preset list instead of requesting it dynamically.
+    $form['preset_list_data_autocomplete'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Custom list data-autocomplete'),
+      '#default_value' => '',
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete' => JSON::encode([
+          'list' => $custom_list,
+        ]),
+      ],
+    ];
+    $form['preset_list_separate_data_attributes'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Custom list separate data attributes'),
+      '#default_value' => '',
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete-list' => JSON::encode($custom_list),
+      ],
+    ];
+
+    // Inputs with the sort results option enabled.
+    $form['sort_data_autocomplete'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Sort data-autocomplete'),
+      '#default_value' => '',
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete' => JSON::encode([
+          'list' => $custom_list,
+          'sort' => TRUE,
+        ]),
+      ],
+    ];
+    $form['sort_separate_data_attributes'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Sort separate data attributes'),
+      '#default_value' => '',
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete-list' => JSON::encode($custom_list),
+        'data-autocomplete-sort' => TRUE,
+      ],
+    ];
+
+    // Inputs with the option to display labels instead of values enabled.
+    $form['display_labels_data_autocomplete'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Display labels data-autocomplete'),
+      '#default_value' => '',
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete' => JSON::encode([
+          'list' => $custom_list,
+          'displayLabels' => TRUE,
+        ]),
+      ],
+    ];
+    $form['display_labels_data_attributes'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Display labels data attributes'),
+      '#default_value' => '',
+      '#autocomplete_route_name' => 'drupal_autocomplete.country_autocomplete',
+      '#attributes' => [
+        'data-autocomplete-list' => JSON::encode($custom_list),
+        'data-autocomplete-display-labels' => 'true',
+      ],
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    // Intentionally empty.
+  }
+
+}
diff --git a/core/modules/drupal_autocomplete/tests/src/FunctionalJavascript/EntityReferenceDrupalAutocompleteWidgetTest.php b/core/modules/drupal_autocomplete/tests/src/FunctionalJavascript/EntityReferenceDrupalAutocompleteWidgetTest.php
new file mode 100644
index 0000000000..a01c94779c
--- /dev/null
+++ b/core/modules/drupal_autocomplete/tests/src/FunctionalJavascript/EntityReferenceDrupalAutocompleteWidgetTest.php
@@ -0,0 +1,438 @@
+<?php
+
+namespace Drupal\Tests\drupal_autocomplete\FunctionalJavascript;
+
+use Behat\Mink\Element\NodeElement;
+use Drupal\FunctionalJavascriptTests\EntityReference\EntityReferenceAutocompleteWidgetTest;
+
+/**
+ * Runs entity reference autocomplete widget tests with drupal_autocomplete.
+ *
+ * @group drupal_autocomplete
+ */
+class EntityReferenceDrupalAutocompleteWidgetTest extends EntityReferenceAutocompleteWidgetTest {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'node',
+    'field_ui',
+    'taxonomy',
+    'drupal_autocomplete',
+    'drupal_autocomplete_test',
+  ];
+
+  /**
+   * Tests aria configuration and screenreader behavior.
+   */
+  public function testScreenreaderAndAria() {
+    $this->createTagsFieldOnPage();
+    $this->createTerm($this->vocabulary, ['name' => 'First']);
+    $this->createTerm($this->vocabulary, ['name' => 'Second']);
+    $this->createTerm($this->vocabulary, ['name' => 'Third']);
+    $this->createTerm($this->vocabulary, ['name' => 'Fourth']);
+    $this->createTerm($this->vocabulary, ['name' => 'Fifth']);
+    $this->createTerm($this->vocabulary, ['name' => 'Sixth']);
+    $this->createTerm($this->vocabulary, ['name' => 'Seventh']);
+    $this->createTerm($this->vocabulary, ['name' => 'Eighth']);
+    $this->createTerm($this->vocabulary, ['name' => 'Ninth']);
+    $this->createTerm($this->vocabulary, ['name' => 'Tenth']);
+    $this->createTerm($this->vocabulary, ['name' => 'Eleventh']);
+    $this->createTerm($this->vocabulary, ['name' => 'Twelfth']);
+    $this->createTerm($this->vocabulary, ['name' => 'Thirteenth']);
+    $this->createTerm($this->vocabulary, ['name' => 'Fourteenth']);
+    $this->createTerm($this->vocabulary, ['name' => 'Fifteenth']);
+    $this->createTerm($this->vocabulary, ['name' => 'Sixteenth']);
+    $this->createTerm($this->vocabulary, ['name' => 'Seventeenth']);
+    $this->createTerm($this->vocabulary, ['name' => 'Eighteenth']);
+    $this->createTerm($this->vocabulary, ['name' => 'Nineteenth']);
+    $this->createTerm($this->vocabulary, ['name' => 'Twentieth']);
+    $this->createTerm($this->vocabulary, ['name' => 'Fancy']);
+    $this->createTerm($this->vocabulary, ['name' => 'Fun']);
+    $this->createTerm($this->vocabulary, ['name' => 'Freaky']);
+    $this->createTerm($this->vocabulary, ['name' => 'Forgettable']);
+    $this->createTerm($this->vocabulary, ['name' => 'Fast']);
+    $this->createTerm($this->vocabulary, ['name' => 'Furious']);
+
+    $this->drupalGet('node/add/page');
+    $page = $this->getSession()->getPage();
+    $assert_session = $this->assertSession();
+
+    $autocomplete_field = $assert_session->waitForElement('css', '[name="taxonomy_reference[target_id]"]');
+    $this->assertEquals('list', $autocomplete_field->getAttribute('aria-autocomplete'));
+    $this->assertEquals('off', $autocomplete_field->getAttribute('autocomplete'));
+    $aria_owns = $autocomplete_field->getAttribute('aria-owns');
+    $this->assertNotNull($aria_owns);
+    $this->assertNotNull($page->find('css', "[data-drupal-autocomplete-list]#$aria_owns"));
+
+    $hint = $this->getDescription($autocomplete_field)->getText();
+    $expected_hint = 'When autocomplete results are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures.';
+    $this->assertEquals($expected_hint, $hint);
+
+    $autocomplete_field->setValue('F');
+    $assert_session->waitOnAutocomplete();
+    $this->assertCount(10, $page->findAll('css', '[data-drupal-autocomplete-list] li'));
+    $this->assertCount(10, $page->findAll('css', '[data-drupal-autocomplete-list] li[aria-selected="false"]'));
+
+    $this->assertScreenreader('There are at least 10 results available. Type additional characters to refine your search.');
+    $autocomplete_field->setValue('Fo');
+    $this->assertScreenreader('There are 3 results available.');
+
+    $autocomplete_field->keyDown(40);
+
+    $this->assertScreenreader('Forgettable (24) 1 of 3 is highlighted');
+    $this->assertFalse($autocomplete_field->hasAttribute('aria-describedby'));
+    $this->assertCount(3, $page->findAll('css', '[data-drupal-autocomplete-list] li'));
+    $this->assertCount(2, $page->findAll('css', '[data-drupal-autocomplete-list] li[aria-selected="false"]'));
+    $this->assertCount(1, $page->findAll('css', '[data-drupal-autocomplete-list] li[aria-selected="true"]'));
+    $active_item = $page->find('css', 'li:contains("Forgettable (24)")');
+    $this->assertEquals('true', $active_item->getAttribute('aria-selected'));
+    $active_item->keyDown(40);
+
+    $this->assertScreenreader('Fourteenth (14) 2 of 3 is highlighted');
+    $this->assertCount(3, $page->findAll('css', '[data-drupal-autocomplete-list] li'));
+    $this->assertCount(2, $page->findAll('css', '[data-drupal-autocomplete-list] li[aria-selected="false"]'));
+    $this->assertCount(1, $page->findAll('css', '[data-drupal-autocomplete-list] li[aria-selected="true"]'));
+    $active_item = $page->find('css', 'li:contains("Fourteenth (14)")');
+    $this->assertEquals('true', $active_item->getAttribute('aria-selected'));
+  }
+
+  /**
+   * Tests the configurable options of the DrupalAutocomplete class.
+   *
+   *  Options can be set directly on an element in two ways:
+   *  - Using a data-autocomplete-(dash separated option name) attribute.
+   *    ex: data-autocomplete-min-chars="2"
+   *  - The data-autocomplete attribute has a JSON string with all custom
+   *    options. The option properties are camel cased.
+   *    ex: data-autocomplete="{"minChars": 2}"
+   * Every option tested is done on an input that uses the
+   * data-autocomplete={option: value}  approach, and separate input using the
+   * data-autocomplete-(dash separated option name) approach.
+   */
+  public function testAutocompleteOptions() {
+    $this->drupalGet('drupal_autocomplete/test-form');
+    $page = $this->getSession()->getPage();
+    $assert_session = $this->assertSession();
+
+    // Test the minChar: option.
+    foreach ([
+      'edit-two-minchar-data-autocomplete',
+      'edit-two-minchar-separate-data-attributes',
+    ] as $id) {
+      $input = $page->findById($id);
+      $list = $this->getList($input);
+      $description = $this->getDescription($input);
+
+      // The minChar option provides additional content to screenreaders on
+      // initial focus, so test for that here.
+      // If an input already has a description associated
+      // with it. That means the screenreader instructions must be added to the
+      // description in a visually-hidden container.
+      $inserted_screenreader_only_description = $description->find('css', '[data-drupal-autocomplete-assistive-hint]');
+      if ($id === 'edit-two-minchar-data-autocomplete') {
+        // This input has a pre-existing description, so check for the visually
+        // hidden supplement to the description.
+        $this->assertNotNull($inserted_screenreader_only_description);
+        $expected_description = 'This also tests appending minchar screenreader hints to descriptions Type 2 or more characters for results. When autocomplete results are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures.';
+        $expected_screenreader_only_description = 'Type 2 or more characters for results. When autocomplete results are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures.';
+        $this->assertEquals($expected_description, $description->getText());
+        $this->assertEquals($expected_screenreader_only_description, $inserted_screenreader_only_description->getText());
+      }
+      if ($id === 'edit-two-minchar-separate-data-attributes') {
+        // This input does not have a pre-existing description, so a visually
+        // hidden one is added solely for assistive tech.
+        $this->assertNull($inserted_screenreader_only_description);
+        $this->assertTrue($description->hasClass('visually-hidden'));
+        $expected_description = 'Type 2 or more characters for results. When autocomplete results are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures.';
+        $this->assertEquals($expected_description, $description->getText());
+      }
+
+      $this->setAutocompleteValue($input, $id, 'U');
+      $assert_session->waitOnAutocomplete();
+      $this->assertCount(0, $list->findAll('css', 'li'));
+      $this->setAutocompleteValue($input, $id, 'Un');
+      $assert_session->waitOnAutocomplete();
+      $this->assertCount(3, $list->findAll('css', 'li'));
+
+      if ($id === 'edit-two-minchar-data-autocomplete') {
+        $inserted_screenreader_only_description = $description->find('css', '[data-drupal-autocomplete-assistive-hint]');
+        $this->assertNull($inserted_screenreader_only_description);
+        $expected_description = 'This also tests appending minchar screenreader hints to descriptions';
+        $this->assertEquals($expected_description, $description->getText());
+      }
+      if ($id === 'edit-two-minchar-separate-data-attributes') {
+        $this->assertFalse($input->hasAttribute('aria-describedby'));
+      }
+
+      // Reset value to ensure the next input isn't obscured.
+      $this->setAutocompleteValue($input, $id, ' ');
+    }
+
+    // Test the firstCharDenylist option.
+    foreach ([
+      'edit-denylist-data-autocomplete',
+      'edit-denylist-separate-data-attributes',
+    ] as $id) {
+      $input = $page->findById($id);
+      $list = $this->getList($input);
+      $this->setAutocompleteValue($input, $id, 'u');
+      $assert_session->waitOnAutocomplete();
+      $this->assertCount(0, $list->findAll('css', 'li'));
+      $this->setAutocompleteValue($input, $id, 'z');
+      $assert_session->waitOnAutocomplete();
+      $this->assertCount(3, $list->findAll('css', 'li'));
+
+      // Reset value to ensure the next input isn't obscured.
+      $this->setAutocompleteValue($input, $id, ' ');
+    }
+
+    // Test setting custom classes via options.
+    foreach ([
+      'edit-custom-classes-data-autocomplete',
+      'edit-custom-classes-separate-data-attributes',
+    ] as $id) {
+      $input = $page->findById($id);
+      $list = $this->getList($input);
+      $this->assertTrue($input->hasClass('class-added-to-input'));
+      $this->assertTrue($input->hasClass('another-class-added-to-input'));
+      $this->assertTrue($list->hasClass('class-added-to-ul'));
+      $this->assertTrue($list->hasClass('another-class-added-to-ul'));
+      $this->setAutocompleteValue($input, $id, 'z');
+      $assert_session->waitOnAutocomplete();
+      $this->assertCount(3, $list->findAll('css', 'li'));
+      $this->assertCount(3, $list->findAll('css', 'li.class-added-to-item'));
+      $this->assertCount(3, $list->findAll('css', 'li.another-class-added-to-item'));
+
+      // Reset value to ensure the next input isn't obscured.
+      $this->setAutocompleteValue($input, $id, ' ');
+    }
+
+    // Test cardinality: and separatorChar: options.
+    foreach ([
+      'edit-cardinality-separator-data-autocomplete',
+      'edit-cardinality-separator-separate-data-attributes',
+    ] as $id) {
+      $input = $page->findById($id);
+      $list = $this->getList($input);
+      $this->setAutocompleteValue($input, $id, 'z');
+      $assert_session->waitOnAutocomplete();
+      $this->assertCount(3, $list->findAll('css', 'li'));
+      $this->setAutocompleteValue($input, $id, 'South Africa (ZA),z');
+      $assert_session->waitOnAutocomplete();
+      $this->assertCount(0, $list->findAll('css', 'li'), $id);
+      $this->setAutocompleteValue($input, $id, 'South Africa (ZA)|z');
+      $assert_session->waitOnAutocomplete();
+      $this->assertCount(2, $list->findAll('css', 'li'), $id);
+      $this->setAutocompleteValue($input, $id, 'South Africa (ZA)|Zambia (ZM)|z');
+      $assert_session->waitOnAutocomplete();
+
+      // No results are available despite there being another "z" item available
+      // (Zimbabwe), because cardinality is set to 2 items.
+      $this->assertCount(0, $list->findAll('css', 'li'));
+
+      // Confirm that a search for 'a' only provides 10 results, to confirm that
+      // the upcoming maxitems test is accurate.
+      $this->setAutocompleteValue($input, $id, 'a');
+      $assert_session->waitOnAutocomplete();
+      $this->assertCount(10, $list->findAll('css', 'li'));
+
+      // Reset value to ensure the next input isn't obscured.
+      $this->setAutocompleteValue($input, $id, ' ');
+    }
+
+    // Test the maxItems: option.
+    foreach ([
+      'edit-maxitems-data-autocomplete',
+      'edit-maxitems-separate-data-attributes',
+    ] as $id) {
+      $input = $page->findById($id);
+      $list = $this->getList($input);
+      $this->setAutocompleteValue($input, $id, 'a');
+      $assert_session->waitOnAutocomplete();
+      $this->assertCount(19, $list->findAll('css', 'li'));
+
+      // Reset value to ensure the next input isn't obscured.
+      $this->setAutocompleteValue($input, $id, ' ');
+    }
+
+    // Test the list: option, which provides a predefined list instead of a
+    // a dynamic request.
+    foreach ([
+      'edit-preset-list-separate-data-attributes',
+      'edit-preset-list-data-autocomplete',
+    ] as $id) {
+      $input = $page->findById($id);
+      $list = $this->getList($input);
+      $this->setAutocompleteValue($input, $id, 'a');
+
+      $expected = [
+        'Zebra Value',
+        'Rhino Value',
+        'Cheetah Value',
+        'Meerkat Value',
+      ];
+      $list_contents = $list->findAll('css', 'li');
+      $this->assertCount(4, $list_contents, $list->getHtml());
+      foreach ($list_contents as $index => $list_item) {
+        $this->assertEquals($expected[$index], $list_item->find('css', 'a')->getText(), $id);
+      }
+      $this->setAutocompleteValue($input, $id, 'h');
+      $assert_session->waitOnAutocomplete();
+      $expected = [
+        'Rhino Value',
+        'Cheetah Value',
+      ];
+      $list_contents = $list->findAll('css', 'li');
+      $this->assertCount(2, $list_contents, $list->getHtml());
+      foreach ($list_contents as $index => $list_item) {
+        $this->assertEquals($expected[$index], $list_item->getText());
+      }
+      // Reset value to ensure the next input isn't obscured.
+      $this->setAutocompleteValue($input, $id, ' ');
+    }
+
+    // Test the sort: option.
+    foreach ([
+      'edit-sort-data-autocomplete',
+      'edit-sort-separate-data-attributes',
+    ] as $id) {
+      $input = $page->findById($id);
+      $list = $this->getList($input);
+      $this->setAutocompleteValue($input, $id, 'a');
+
+      $expected = [
+        'Cheetah Value',
+        'Meerkat Value',
+        'Rhino Value',
+        'Zebra Value',
+      ];
+      $list_contents = $list->findAll('css', 'li');
+      $this->assertCount(4, $list_contents, $list->getHtml());
+      foreach ($list_contents as $index => $list_item) {
+        $this->assertEquals($expected[$index], $list_item->find('css', 'a')->getText(), $id);
+      }
+      $this->setAutocompleteValue($input, $id, 'h');
+      $assert_session->waitOnAutocomplete();
+      $expected = [
+        'Cheetah Value',
+        'Rhino Value',
+      ];
+      $list_contents = $list->findAll('css', 'li');
+      $this->assertCount(2, $list_contents, $list->getHtml());
+      foreach ($list_contents as $index => $list_item) {
+        $this->assertEquals($expected[$index], $list_item->getText());
+      }
+      // Reset value to ensure the next input isn't obscured.
+      $this->setAutocompleteValue($input, $id, ' ');
+    }
+
+    // Test the displayLabels: option.
+    foreach ([
+      'edit-display-labels-data-autocomplete',
+      'edit-display-labels-data-attributes',
+    ] as $id) {
+      $input = $page->findById($id);
+      $list = $this->getList($input);
+      $this->setAutocompleteValue($input, $id, 'a');
+
+      $expected = [
+        'Zebra Label',
+        'Rhino Label',
+        'Cheetah Label',
+        'Meerkat Label',
+      ];
+      $list_contents = $list->findAll('css', 'li');
+      $this->assertCount(4, $list_contents, $list->getHtml());
+      foreach ($list_contents as $index => $list_item) {
+        $this->assertEquals($expected[$index], $list_item->find('css', 'a')->getText(), $id);
+      }
+      $this->setAutocompleteValue($input, $id, 'h');
+      $assert_session->waitOnAutocomplete();
+      $expected = [
+        'Rhino Label',
+        'Cheetah Label',
+      ];
+      $list_contents = $list->findAll('css', 'li');
+      $this->assertCount(2, $list_contents, $list->getHtml());
+      foreach ($list_contents as $index => $list_item) {
+        $this->assertEquals($expected[$index], $list_item->getText());
+      }
+      // Reset value to ensure the next input isn't obscured.
+      $this->setAutocompleteValue($input, $id, ' ');
+    }
+  }
+
+  /**
+   * Confirms the expected message is in drupal-live-announce.
+   *
+   * @param string $message
+   *   The message expected to be in #drupal-live-announce.
+   */
+  public function assertScreenreader($message) {
+    // Use assertJsCondition() instead of a standard DOM assertion in order to
+    // leverage wait(). With DrupalAutocomplete, the updating of
+    // #drupal-live-announce is intentionally delayed to prevent collision with
+    // browser default screenreader announcements.
+    $this->assertJsCondition('document.getElementById("drupal-live-announce").innerText.includes("' . $message . '")', 10000, "Live region did not include: $message");
+  }
+
+  /**
+   * Sets the value of an autocomplete input.
+   *
+   * @param \Behat\Mink\Element\NodeElement $input
+   *   The autocomplete input element.
+   * @param string $id
+   *   The id of the input element.
+   * @param string $value
+   *   The value to set.
+   */
+  public function setAutocompleteValue(NodeElement $input, $id, $value) {
+    // Before setting a value, the the autocomplete instance must set the
+    // preventCloseOnBlur property to false. This is due to setValue() blurring
+    // the element to force triggering of the change event. Without
+    // preventCloseOnBlur set to true, that blur event will close the suggestion
+    // list moments after it is opened.
+    // @see \Behat\Mink\Driver\Selenium2Driver::setValue
+    $this->getSession()->executeScript('Drupal.Autocomplete.instances["' . $id . '"].preventCloseOnBlur = true');
+    $input->setValue($value);
+    $this->assertSession()->waitOnAutocomplete();
+    $this->getSession()->executeScript('Drupal.Autocomplete.instances["' . $id . '"].preventCloseOnBlur = false');
+  }
+
+  /**
+   * Gets the suggestion list associated with an input.
+   *
+   * @param \Behat\Mink\Element\NodeElement $input
+   *   The autocomplete input element.
+   *
+   * @return \Behat\Mink\Element\NodeElement
+   *   The suggestion list.
+   */
+  public function getList(NodeElement $input) {
+    $wrapper = $input->getParent();
+    $this->assertNotNull($wrapper);
+    $this->assertTrue($wrapper->hasAttribute('data-drupal-autocomplete-wrapper'));
+    $list = $wrapper->find('css', '[data-drupal-autocomplete-list]');
+    $this->assertNotNull($list);
+    return $list;
+  }
+
+  /**
+   * Gets the description associated with an input.
+   *
+   * @param \Behat\Mink\Element\NodeElement $input
+   *   The autocomplete input element.
+   *
+   * @return \Behat\Mink\Element\NodeElement
+   *   The element containing the description.
+   */
+  public function getDescription(NodeElement $input) {
+    $aria_describedby = $input->getAttribute('aria-describedby');
+    $description = $this->getSession()->getPage()->findById($aria_describedby);
+    $this->assertNotNull($description);
+    return $description;
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/EntityReference/EntityReferenceAutocompleteWidgetTest.php b/core/tests/Drupal/FunctionalJavascriptTests/EntityReference/EntityReferenceAutocompleteWidgetTest.php
index 54db002351..14b1b3d18f 100644
--- a/core/tests/Drupal/FunctionalJavascriptTests/EntityReference/EntityReferenceAutocompleteWidgetTest.php
+++ b/core/tests/Drupal/FunctionalJavascriptTests/EntityReference/EntityReferenceAutocompleteWidgetTest.php
@@ -2,10 +2,12 @@
 
 namespace Drupal\FunctionalJavascriptTests\EntityReference;
 
+use Drupal\Core\Field\FieldStorageDefinitionInterface;
 use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
 use Drupal\Tests\field\Traits\EntityReferenceTestTrait;
 use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
 use Drupal\Tests\node\Traits\NodeCreationTrait;
+use Drupal\Tests\taxonomy\Traits\TaxonomyTestTrait;
 
 /**
  * Tests the output of entity reference autocomplete widgets.
@@ -17,11 +19,19 @@ class EntityReferenceAutocompleteWidgetTest extends WebDriverTestBase {
   use ContentTypeCreationTrait;
   use EntityReferenceTestTrait;
   use NodeCreationTrait;
+  use TaxonomyTestTrait;
 
   /**
    * {@inheritdoc}
    */
-  protected static $modules = ['node', 'field_ui'];
+  protected static $modules = ['node', 'taxonomy', 'field_ui'];
+
+  /**
+   * The test vocabulary.
+   *
+   * @var \Drupal\taxonomy\VocabularyInterface
+   */
+  protected $vocabulary;
 
   /**
    * {@inheritdoc}
@@ -38,6 +48,7 @@ protected function setUp(): void {
     $this->createContentType(['type' => 'page']);
     $this->createNode(['title' => 'Test page']);
     $this->createNode(['title' => 'Page test']);
+    $this->createNode(['title' => 'Guess me']);
 
     $user = $this->drupalCreateUser([
       'access content',
@@ -56,7 +67,14 @@ public function testEntityReferenceAutocompleteWidget() {
     // Create an entity reference field and use the default 'CONTAINS' match
     // operator.
     $field_name = 'field_test';
-    $this->createEntityReferenceField('node', 'page', $field_name, $field_name, 'node', 'default', ['target_bundles' => ['page'], 'sort' => ['field' => 'title', 'direction' => 'DESC']]);
+    $selection_handler_settings = [
+      'target_bundles' => ['page'],
+      'sort' => [
+        'field' => 'title',
+        'direction' => 'DESC',
+      ],
+    ];
+    $this->createEntityReferenceField('node', 'page', $field_name, $field_name, 'node', 'default', $selection_handler_settings);
     $form_display = $display_repository->getFormDisplay('node', 'page');
     $form_display->setComponent($field_name, [
       'type' => 'entity_reference_autocomplete',
@@ -64,6 +82,14 @@ public function testEntityReferenceAutocompleteWidget() {
         'match_operator' => 'CONTAINS',
       ],
     ]);
+
+    $display_repository->getViewDisplay('node', 'page')
+      ->setComponent($field_name, [
+          'type' => 'entity_reference_label',
+          'weight' => 10,
+        ])
+      ->save();
+
     // To satisfy config schema, the size setting must be an integer, not just
     // a numeric value. See https://www.drupal.org/node/2885441.
     $this->assertIsInt($form_display->getComponent($field_name)['settings']['size']);
@@ -79,7 +105,6 @@ public function testEntityReferenceAutocompleteWidget() {
     $autocomplete_field->setValue('Test');
     $this->getSession()->getDriver()->keyDown($autocomplete_field->getXpath(), ' ');
     $assert_session->waitOnAutocomplete();
-
     $results = $page->findAll('css', '.ui-autocomplete li');
 
     $this->assertCount(2, $results);
@@ -127,13 +152,12 @@ public function testEntityReferenceAutocompleteWidget() {
 
     // Change the size of the result set via the UI.
     $this->drupalLogin($this->createUser([
-        'access content',
-        'administer content types',
-        'administer node fields',
-        'administer node form display',
-        'create page content',
-      ]
-    ));
+      'access content',
+      'administer content types',
+      'administer node fields',
+      'administer node form display',
+      'create page content',
+    ]));
     $this->drupalGet('/admin/structure/types/manage/page/form-display');
     $assert_session->pageTextContains('Autocomplete suggestion list size: 1');
     // Click on the widget settings button to open the widget settings form.
@@ -148,6 +172,15 @@ public function testEntityReferenceAutocompleteWidget() {
 
     $this->doAutocomplete($field_name);
     $this->assertCount(2, $page->findAll('css', '.ui-autocomplete li'));
+
+    // Test that an entity reference is saved by providing just the title,
+    // without the addition of the entity ID in parentheses.
+    $this->drupalGet('node/add/page');
+    $page->fillField('Title', 'Testing that the autocomplete field does not require the entity id');
+    $autocomplete_field = $assert_session->waitForElement('css', '[name="' . $field_name . '[0][target_id]"].ui-autocomplete-input');
+    $autocomplete_field->setValue('Guess me');
+    $page->pressButton('Save');
+    $assert_session->elementExists('css', '[href$="/node/3"]:contains("Guess me")');
   }
 
   /**
@@ -163,4 +196,71 @@ protected function doAutocomplete($field_name) {
     $this->assertSession()->waitOnAutocomplete();
   }
 
+  /**
+   * Test that spaces and commas work properly with autocomplete fields.
+   */
+  public function testSeparators() {
+    $this->createTagsFieldOnPage();
+
+    $term_commas = 'I,love,commas';
+    $term_spaces = 'Just a fan of spaces';
+    $term_commas_spaces = 'I dig both commas and spaces, apparently';
+
+    $this->createTerm($this->vocabulary, ['name' => $term_commas]);
+    $this->createTerm($this->vocabulary, ['name' => $term_spaces]);
+    $this->createTerm($this->vocabulary, ['name' => $term_commas_spaces]);
+
+    $this->drupalGet('node/add/page');
+    $page = $this->getSession()->getPage();
+    $assert_session = $this->assertSession();
+
+    $autocomplete_field = $assert_session->waitForElement('css', '[name="taxonomy_reference[target_id]"]');
+    $autocomplete_field->setValue('a');
+    $this->getSession()->getDriver()->keyDown($autocomplete_field->getXpath(), ' ');
+    $assert_session->waitOnAutocomplete();
+
+    $results = $page->findAll('css', '.ui-autocomplete li');
+    $this->assertCount(3, $results);
+
+    $assert_session->elementExists('css', '.ui-autocomplete li:contains("' . $term_commas_spaces . '")')->click();
+    $assert_session->pageTextNotContains($term_commas);
+    $assert_session->pageTextNotContains($term_spaces);
+    $current_value = $autocomplete_field->getValue();
+    $this->assertStringContainsString($term_commas_spaces, $current_value);
+  }
+
+  /**
+   * Create a tags field on the Page content type.
+   */
+  public function createTagsFieldOnPage() {
+    /** @var \Drupal\Core\Entity\EntityDisplayRepositoryInterface $display_repository */
+    $display_repository = \Drupal::service('entity_display.repository');
+
+    $field_name = 'taxonomy_reference';
+    $vocabulary = $this->createVocabulary();
+    $this->vocabulary = $vocabulary;
+
+    $handler_settings = [
+      'target_bundles' => [
+        $vocabulary->id() => $vocabulary->id(),
+      ],
+      'auto_create' => TRUE,
+    ];
+    $this->createEntityReferenceField('node', 'page', $field_name, 'Tags', 'taxonomy_term', 'default', $handler_settings, FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED);
+    $display_repository->getFormDisplay('node', 'page')
+      ->setComponent($field_name, [
+        'type' => 'entity_reference_autocomplete_tags',
+        'settings' => [
+          'match_operator' => 'CONTAINS',
+        ],
+      ])
+      ->save();
+    $display_repository->getViewDisplay('node', 'page')
+      ->setComponent($field_name, [
+        'type' => 'entity_reference_label',
+        'weight' => 10,
+      ])
+      ->save();
+  }
+
 }
diff --git a/core/tests/Drupal/KernelTests/Core/Asset/DeprecatedJqueryUiAssetsTest.php b/core/tests/Drupal/KernelTests/Core/Asset/DeprecatedJqueryUiAssetsTest.php
index 13cc2c97ba..52f6b94c25 100644
--- a/core/tests/Drupal/KernelTests/Core/Asset/DeprecatedJqueryUiAssetsTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Asset/DeprecatedJqueryUiAssetsTest.php
@@ -21,7 +21,7 @@ public function testDeprecatedJqueryUi() {
     $library_discovery = $this->container->get('library.discovery');
     $deprecated_jquery_ui_libraries = [
       'jquery.ui' => '1396fab9268ee2cce47df6ac3e4781c8',
-      'jquery.ui.autocomplete' => '153f2836f8f2da39767208b6e09cb5b4',
+      'jquery.ui.autocomplete' => '553de811b8dc0e849416a086a400e724',
       'jquery.ui.button' => 'ad23e5de0fa1de1f511d10ba2e10d2dd',
       'jquery.ui.dialog' => 'dc72e5bd38a3d2697bcf3e7964852e4b',
       'jquery.ui.draggable' => 'af0f2bdc8aa4ade1e3de8042f31a9312',
@@ -35,7 +35,8 @@ public function testDeprecatedJqueryUi() {
     // this test.
     ini_set('serialize_precision', -1);
     foreach ($deprecated_jquery_ui_libraries as $library => $expected_hashed_library_definition) {
-      $this->expectDeprecation("The \"core/$library\" asset library is deprecated in drupal:9.2.0 and is removed from drupal:10.0.0. See https://www.drupal.org/node/3067969");
+      $issue_id = $library === 'jquery.ui.autocomplete' ? '3083715' : '3067969';
+      $this->expectDeprecation("The \"core/$library\" asset library is deprecated in drupal:9.2.0 and is removed from drupal:10.0.0. See https://www.drupal.org/node/$issue_id");
       $library_definition = $library_discovery->getLibraryByName('core', $library);
       $this->assertEquals($expected_hashed_library_definition, md5(serialize($library_definition)));
     }
diff --git a/core/tests/Drupal/Tests/Listeners/DeprecationListenerTrait.php b/core/tests/Drupal/Tests/Listeners/DeprecationListenerTrait.php
index 3307d4b12d..76e11f592a 100644
--- a/core/tests/Drupal/Tests/Listeners/DeprecationListenerTrait.php
+++ b/core/tests/Drupal/Tests/Listeners/DeprecationListenerTrait.php
@@ -139,6 +139,7 @@ public static function getSkippedDeprecations() {
       'assertDirectoryNotIsWritable() is deprecated and will be removed in PHPUnit 10. Refactor your code to use assertDirectoryIsNotWritable() instead.',
       'assertFileNotIsWritable() is deprecated and will be removed in PHPUnit 10. Refactor your code to use assertFileIsNotWritable() instead.',
       'The at() matcher has been deprecated. It will be removed in PHPUnit 10. Please refactor your test to not rely on the order in which methods are invoked.',
+      'The "core/jquery.ui.autocomplete" asset library is deprecated in drupal:9.2.0 and is removed from drupal:10.0.0. See https://www.drupal.org/node/3083715',
     ];
   }
 
diff --git a/core/themes/claro/css/components/jquery.ui/theme.css b/core/themes/claro/css/components/jquery.ui/theme.css
index 29a06c2bbd..f47df8b541 100644
--- a/core/themes/claro/css/components/jquery.ui/theme.css
+++ b/core/themes/claro/css/components/jquery.ui/theme.css
@@ -623,19 +623,23 @@
   text-decoration: none;
 }
 
-.ui-autocomplete .ui-menu-item-wrapper.ui-state-active {
+.ui-autocomplete .ui-menu-item.ui-state-focus,
+.autocomplete .ui-menu-item.ui-state-hover,
+[data-drupal-autocomplete-list] .ui-menu-item:hover {
   margin: 0;
-  color: #fff;
-  background: #003cc5;
+  background: #0072b9;
 }
 
-.ui-autocomplete .ui-menu-item.ui-state-focus,
-.autocomplete .ui-menu-item.ui-state-hover {
+.ui-autocomplete .ui-menu-item-wrapper.ui-state-active,
+[data-drupal-autocomplete-list] .ui-menu-item:focus {
   margin: 0;
-  background: #0072b9;
+  color: #fff;
+  background: #003cc5;
+  box-shadow: none;
 }
 
 .ui-autocomplete .ui-state-focus a,
-.autocomplete .ui-state-hover a {
+.autocomplete .ui-state-hover a,
+[data-drupal-autocomplete-list] .ui-menu-item:hover {
   color: #fff;
 }
diff --git a/core/themes/claro/css/components/jquery.ui/theme.pcss.css b/core/themes/claro/css/components/jquery.ui/theme.pcss.css
index 9d0f877635..123820aca9 100644
--- a/core/themes/claro/css/components/jquery.ui/theme.pcss.css
+++ b/core/themes/claro/css/components/jquery.ui/theme.pcss.css
@@ -404,17 +404,22 @@
 .ui-autocomplete .ui-menu-item-wrapper:hover {
   text-decoration: none;
 }
-.ui-autocomplete .ui-menu-item-wrapper.ui-state-active {
-  margin: 0;
-  color: var(--jui-dropdown--active-fg-color);
-  background: var(--jui-dropdown--active-bg-color);
-}
 .ui-autocomplete .ui-menu-item.ui-state-focus,
-.autocomplete .ui-menu-item.ui-state-hover {
+.autocomplete .ui-menu-item.ui-state-hover,
+[data-drupal-autocomplete-list] .ui-menu-item:hover {
   margin: 0;
   background: #0072b9;
 }
+.ui-autocomplete .ui-menu-item-wrapper.ui-state-active,
+[data-drupal-autocomplete-list] .ui-menu-item:focus {
+  margin: 0;
+  color: var(--jui-dropdown--active-fg-color);
+  background: var(--jui-dropdown--active-bg-color);
+  box-shadow: none;
+}
+
 .ui-autocomplete .ui-state-focus a,
-.autocomplete .ui-state-hover a {
+.autocomplete .ui-state-hover a,
+[data-drupal-autocomplete-list] .ui-menu-item:hover {
   color: #fff;
 }
