diff --git a/composer.lock b/composer.lock index 8c6000c89a..a93571665a 100644 --- a/composer.lock +++ b/composer.lock @@ -500,7 +500,7 @@ "dist": { "type": "path", "url": "core", - "reference": "493f34276d92722b600d8cf70f7852260a87afd5" + "reference": "4e13d03bc47024576f020c51bec88003371858d9" }, "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/core.libraries.yml b/core/core.libraries.yml index bcc0db0553..e363e317b5 100644 --- a/core/core.libraries.yml +++ b/core/core.libraries.yml @@ -132,6 +132,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: @@ -196,6 +201,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: @@ -203,6 +213,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: @@ -465,7 +486,7 @@ jquery.ui.autocomplete: - core/jquery.ui.widget - core/jquery.ui.position - core/jquery.ui.menu - deprecated: The "%library_id%" asset library is deprecated in drupal:9.1.0 and is removed from drupal:10.0.0. See https://www.drupal.org/project/drupal/issues/3076171 + deprecated: The "%library_id%" asset library is deprecated in drupal:9.2.0 and is removed from drupal:10.0.0. 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 3ccc1fbcc4..b5b15e01fa 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,20 +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. - $name = explode('[', $element['#name'])[0]; - if (!empty($complete_form[$name]['widget']['#theme']) && $complete_form[$name]['widget']['#theme'] === 'field_multiple_value_form') { + $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($complete_form[$name]['widget']['#cardinality'])) { - $cardinality = $complete_form[$name]['widget']['#cardinality']; + elseif (!empty($element_within_form['widget']['#cardinality'])) { + $cardinality = $element_within_form['widget']['#cardinality']; } else { + // A Cardinality of -1 is considered unlimited. $cardinality = -1; } - $complete_form[$name]['#attributes']['data-autocomplete-cardinality'] = $cardinality; + $element['#attributes']['data-autocomplete-cardinality'] = $cardinality; return $element; } 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/d9_autocomplete/css/d9_autocomplete.css b/core/modules/d9_autocomplete/css/d9_autocomplete.css index 24441afe8d..d8e022044b 100644 --- a/core/modules/d9_autocomplete/css/d9_autocomplete.css +++ b/core/modules/d9_autocomplete/css/d9_autocomplete.css @@ -1,23 +1,29 @@ +[data-drupal-autocomplete-wrapper] { + position: relative; +} + [data-drupal-autocomplete-list] { - z-index: 1; + 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 { - padding: 3px 1em 3px 0.4em; + display: list-item; } - -[data-drupal-autocomplete-list] li a { - display: block; -} - [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/d9_autocomplete/css/jqueryui.css b/core/modules/d9_autocomplete/css/jqueryui.css new file mode 100644 index 0000000000..73f18993ef --- /dev/null +++ b/core/modules/d9_autocomplete/css/jqueryui.css @@ -0,0 +1,15 @@ +/* Styling specific to d9_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/d9_autocomplete/d9_autocomplete.info.yml b/core/modules/d9_autocomplete/d9_autocomplete.info.yml index 26a8c4c5a7..73b1a6ea3e 100644 --- a/core/modules/d9_autocomplete/d9_autocomplete.info.yml +++ b/core/modules/d9_autocomplete/d9_autocomplete.info.yml @@ -1,4 +1,4 @@ name: 'Drupal 9 Autocomplete' type: module -description: 'Provides a replacement for jQuery UI Autocomplete. jQuery UI Autocomplete is deprecated in Drupal 8.8.0 and will be removed in Drupal 9.0.' +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/d9_autocomplete/d9_autocomplete.libraries.yml b/core/modules/d9_autocomplete/d9_autocomplete.libraries.yml index 1295942fd5..6e71b600a2 100644 --- a/core/modules/d9_autocomplete/d9_autocomplete.libraries.yml +++ b/core/modules/d9_autocomplete/d9_autocomplete.libraries.yml @@ -1,16 +1,35 @@ autocomplete: version: VERSION js: - js/autocomplete.js: {} + js/drupalautocomplete.js: {} + js/autocomplete-init.js: {} css: component: + css/jqueryui.css: { weight: -1 } css/d9_autocomplete.css: {} + drupalSettings: + autocompleteOptions: + inputClass: ui-autocomplete-input + ulClass: ui-menu ui-widget ui-widget-content ui-autocomplete ui-front + loadingClass: ui-autocomplete-loading + itemClass: ui-menu-item + liveRegion: false dependencies: - core/drupal - core/drupalSettings - core/drupal.ajax - core/drupal.announce + - core/drupal.customevent + - core/drupal.element.closest + - core/drupal.fetch - core/popperjs - # Needed for once() and event listeners. + - core/jqueryui.autocomplete.styles + # jQuery needed for once() and event management. - core/jquery - core/jquery.once + +jqueryui.autocomplete.styles: + version: VERSION + css: + component: + assets/vendor/jquery.ui/themes/base/autocomplete.css: {} diff --git a/core/modules/d9_autocomplete/js/autocomplete-init.es6.js b/core/modules/d9_autocomplete/js/autocomplete-init.es6.js new file mode 100644 index 0000000000..ec72364aa1 --- /dev/null +++ b/core/modules/d9_autocomplete/js/autocomplete-init.es6.js @@ -0,0 +1,69 @@ +(($, Drupal, drupalSettings, DrupalAutocomplete) => { + Drupal.Autocomplete = {}; + Drupal.Autocomplete.instances = []; + + /** + * Attaches the autocomplete behavior to all required fields. + * + * @type {Drupal~behavior} + * + * @prop {Drupal~behaviorAttach} attach + * Attaches the autocomplete behaviors. + */ + Drupal.behaviors.autocomplete = { + attach(context) { + const options = drupalSettings.autocompleteOptions || {}; + const $autoCompleteInputs = $(context) + .find('input.form-autocomplete') + .once('autocomplete-init'); + + DrupalAutocomplete.formatSuggestionItem = (suggestion) => + // Wrap the item text in an ``, 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. + `${suggestion.value.trim()}`; + + 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, + ); + } + + function autocompleteAnnounceResults(count) { + const message = this.resultsMessage(count); + Drupal.announce(message, 'assertive'); + } + + $autoCompleteInputs.each((index, autocompleteInput) => { + const id = autocompleteInput.getAttribute('id'); + Drupal.Autocomplete.instances[id] = new DrupalAutocomplete( + autocompleteInput, + options, + ); + Drupal.Autocomplete.instances[ + id + ].resultsMessage = autocompleteResultsMessage; + Drupal.Autocomplete.instances[ + id + ].announceResults = autocompleteAnnounceResults; + }); + }, + 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/d9_autocomplete/js/autocomplete-init.js b/core/modules/d9_autocomplete/js/autocomplete-init.js new file mode 100644 index 0000000000..68d747ba5d --- /dev/null +++ b/core/modules/d9_autocomplete/js/autocomplete-init.js @@ -0,0 +1,50 @@ +/** +* 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.behaviors.autocomplete = { + attach: function attach(context) { + var options = drupalSettings.autocompleteOptions || {}; + var $autoCompleteInputs = $(context).find('input.form-autocomplete').once('autocomplete-init'); + + DrupalAutocomplete.formatSuggestionItem = function (suggestion) { + return "".concat(suggestion.value.trim(), ""); + }; + + 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 autocompleteAnnounceResults(count) { + var message = this.resultsMessage(count); + Drupal.announce(message, 'assertive'); + } + + $autoCompleteInputs.each(function (index, autocompleteInput) { + var id = autocompleteInput.getAttribute('id'); + Drupal.Autocomplete.instances[id] = new DrupalAutocomplete(autocompleteInput, options); + Drupal.Autocomplete.instances[id].resultsMessage = autocompleteResultsMessage; + Drupal.Autocomplete.instances[id].announceResults = autocompleteAnnounceResults; + }); + }, + 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/d9_autocomplete/js/autocomplete.es6.js b/core/modules/d9_autocomplete/js/autocomplete.es6.js deleted file mode 100644 index c27d35e534..0000000000 --- a/core/modules/d9_autocomplete/js/autocomplete.es6.js +++ /dev/null @@ -1,492 +0,0 @@ -/** - * @file - * Standalone autocomplete. - */ - -(($, Drupal, drupalSettings) => { - Drupal.Autocomplete = class { - 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.count = document.querySelectorAll( - '[data-drupal-autocomplete-initialized]', - ).length; - const listboxId = `autocomplete-listbox-${this.count}`; - - const defaultOptions = { - firstCharacterDenylist: ',', - minChars: 1, - maxItems: 10, - sort: false, - }; - this.options = { ...defaultOptions, ...options }; - this.preventClose = false; - this.isOpened = false; - this.cache = []; - this.suggestionItems = []; - this.hasAnnouncedOnce = false; - this.input = input; - this.input.setAttribute('data-drupal-autocomplete-initialized', ''); - this.input.setAttribute('aria-autocomplete', 'list'); - this.input.setAttribute('autocomplete', 'off'); - this.input.setAttribute('data-drupal-autocomplete-input', ''); - this.input.setAttribute('aria-owns', listboxId); - this.input.setAttribute('role', 'combobox'); - this.input.setAttribute('aria-expanded', 'false'); - this.ul = document.createElement('ul'); - this.ul.setAttribute('role', 'listbox'); - this.ul.setAttribute('data-drupal-autocomplete-list', ''); - this.ul.setAttribute('id', listboxId); - this.ul.setAttribute('hidden', ''); - this.input.parentNode.appendChild(this.ul); - - // Add classes that were previously provided by jQuery UI. - this.input.classList.add('ui-autocomplete-input'); - this.ul.classList.add('ui-autocomplete'); - - $(this.input).on('input', () => this.inputListener()); - $(this.input).on('blur', (e) => this.blurHandler(e)); - $(this.input).on('keydown', (e) => this.inputKeyDown(e)); - $(this.ul).on('mousedown', (e) => e.preventDefault()); - $(this.ul).on('click', (e) => this.itemClick(e)); - $(this.ul).on('keydown', (e) => this.listKeyDown(e)); - $(this.ul).on('blur', (e) => this.blurHandler(e)); - } - - /** - * Handles blur events. - * - * @param {Event} e - * The blur event. - */ - blurHandler(e) { - if (this.preventClose) { - this.preventClose = false; - e.preventDefault(); - } else { - this.close(); - } - } - - listKeyDown(e) { - if ( - !this.ul.contains(document.activeElement) || - e.ctrlKey || - e.altKey || - e.metaKey || - e.keyCode === this.keyCode.TAB - ) { - return; - } - - 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(); - } - - focusPrev() { - this.preventClose = 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) { - previousItem.focus(); - } - } - - focusNext() { - this.preventClose = true; - 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) { - nextItem.focus(); - } - } - - inputKeyDown(e) { - const { keyCode } = e; - if (this.isOpened) { - if (keyCode === this.keyCode.ESC) { - this.close(); - } - if (keyCode === this.keyCode.DOWN) { - e.preventDefault(); - this.preventClose = true; - this.ul.querySelector('li').focus(); - } - } - } - - itemClick(e) { - const li = e.target; - if (li && e.button === 0) { - this.replaceInputValue(li.textContent); - e.preventDefault(); - this.close(); - } - } - - /** - * 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 cardinality = this.getCardinality(); - const numItems = this.splitValues().length; - - // Add a comma separator if the field allows additional items. - const separator = - numItems < cardinality || parseInt(cardinality, 10) === 0 ? ',' : ''; - const before = this.input.value.match(/^.+,\s*|/)[0]; - this.input.value = `${before}${item}${separator}`; - } - - inputListener() { - const inputId = this.input.getAttribute('id'); - const searchTerm = this.extractLastEntityReference(); - - 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(); - this.announceSuggestionCount(this.ul.children.length); - } else { - const apiUrl = this.input.getAttribute('data-autocomplete-path'); - this.input.classList.add('ui-autocomplete-loading'); - const xhr = new XMLHttpRequest(); - xhr.open('GET', `${apiUrl}?q=${searchTerm}`); - xhr.onload = () => { - this.input.classList.remove('ui-autocomplete-loading'); - if (xhr.status === 200) { - const results = JSON.parse(xhr.response); - this.suggestionItems = results; - this.displayResults(); - - this.cache[inputId][searchTerm] = results; - this.announceSuggestionCount(results.length); - } - }; - xhr.send(); - } - } - } - - displayResults() { - const { announce } = Drupal; - const typed = this.extractLastEntityReference(); - if ( - typed.length >= this.options.minChars && - this.suggestionItems.length > 0 - ) { - this.ul.innerHTML = ''; - this.suggestions = this.suggestionItems.filter((item) => - this.filterResults(item, typed), - ); - - if (this.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(); - announce(Drupal.t('No results found')); - } else { - this.open(); - this.announceSuggestionCount(this.ul.children.length); - } - } else { - this.close(); - announce(Drupal.t('No results found')); - } - } - - /** - * 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'); - // Wrapped in an `` for backwards compatibility. - li.innerHTML = `${suggestion.value.trim()}`; - 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; - } - - /** - * Opens the suggestion list. - */ - open() { - this.input.setAttribute('aria-expanded', 'true'); - this.ul.removeAttribute('hidden'); - this.isOpened = true; - this.ul.style.minWidth = `${this.input.offsetWidth - 4}px`; - if (!this.hasOwnProperty('popper')) { - this.initPopper(); - } else { - this.popper.forceUpdate(); - } - } - - /** - * Closes the suggestion list. - */ - close() { - this.input.setAttribute('aria-expanded', 'false'); - this.ul.setAttribute('hidden', ''); - this.isOpened = false; - } - - /** - * Returns the last value of an multi-value textfield. - * - * @return {string} - * The last value of the input field. - */ - extractLastEntityReference() { - 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 === ',' && !quote) { - result.push(current.trim()); - current = ''; - } else { - current += character; - } - } - if (value.length > 0) { - result.push(current.trim()); - } - return result; - } - - /** - * Provides information on suggestion count to screenreaders. - * - * @param {number} count - * The number of results present. - */ - announceSuggestionCount(count) { - const { announce, formatPlural } = Drupal; - const { maxItems } = this.options; - - // If the number of suggestions provided equals the maximum allowed, - // provide a different message so users are aware there may be additional - // suggestions that match their criteria. - const pluralMessage = - maxItems === count - ? 'There are at least @count results available. Type additional characters to refine your search.' - : 'There are @count results available.'; - const message = formatPlural( - count, - 'There is one result available.', - pluralMessage, - ); - - announce(Drupal.t(message), 'assertive'); - } - - /** - * 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 } = this.options; - const cardinality = this.getCardinality(); - const suggestionValue = suggestion.value; - const currentValues = this.splitValues(); - - const inputSpecificFirstCharDenylist = this.input.hasAttribute( - 'data-autocomplete-first-character-denylist', - ) - ? this.input.getAttribute('data-autocomplete-first-character-denylist') - : ''; - - // 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 || - inputSpecificFirstCharDenylist.indexOf(typed[0]) !== -1 || - currentValues.indexOf(suggestionValue) !== -1 || - (cardinality > 0 && currentValues.length > cardinality) - ) { - return false; - } - - return RegExp( - this.extractLastEntityReference() - .trim() - .replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&'), - 'i', - ).test(suggestionValue); - } - - /** - * Returns the configured cardinality of the field, when available. - * - * @return {number} - * The cardinality of the field. Returns -1 if it is not explicitly - * configured, which is interpreted as unlimited. - */ - getCardinality() { - const wrapper = this.input.closest('[data-autocomplete-cardinality]'); - return wrapper - ? wrapper.getAttribute('data-autocomplete-cardinality') - : -1; - } - - /** - * Initialize positioning of items with PopperJS. - */ - initPopper() { - this.popper = Popper.createPopper(this.input, this.ul, { - placement: 'bottom-start', - modifiers: [ - { - name: 'flip', - options: { - fallbackPlacements: [], - }, - }, - ], - }); - } - }; - - Drupal.Autocomplete.instances = []; - - /** - * Attaches the autocomplete behavior to all required fields. - * - * @type {Drupal~behavior} - * - * @prop {Drupal~behaviorAttach} attach - * Attaches the autocomplete behaviors. - */ - Drupal.behaviors.autocomplete = { - attach(context) { - const options = drupalSettings.autocompleteOptions; - - const $autoCompleteInputs = $(context) - .find('input.form-autocomplete') - .once('autocomplete-init'); - - Drupal.Autocomplete.instances = Drupal.Autocomplete.instances.concat( - $autoCompleteInputs - .map( - (index, autocompleteInput) => - new Drupal.Autocomplete(autocompleteInput, options), - ) - .get(), - ); - }, - }; -})(jQuery, Drupal, drupalSettings, Popper); diff --git a/core/modules/d9_autocomplete/js/autocomplete.js b/core/modules/d9_autocomplete/js/autocomplete.js deleted file mode 100644 index cd73418ec2..0000000000 --- a/core/modules/d9_autocomplete/js/autocomplete.js +++ /dev/null @@ -1,421 +0,0 @@ -/** -* 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; } - -(function ($, Drupal, drupalSettings) { - Drupal.Autocomplete = function () { - function _class(input) { - var _this = this; - - var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; - - _classCallCheck(this, _class); - - 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.count = document.querySelectorAll('[data-drupal-autocomplete-initialized]').length; - var listboxId = "autocomplete-listbox-".concat(this.count); - var defaultOptions = { - firstCharacterDenylist: ',', - minChars: 1, - maxItems: 10, - sort: false - }; - this.options = _objectSpread(_objectSpread({}, defaultOptions), options); - this.preventClose = false; - this.isOpened = false; - this.cache = []; - this.suggestionItems = []; - this.hasAnnouncedOnce = false; - this.input = input; - this.input.setAttribute('data-drupal-autocomplete-initialized', ''); - this.input.setAttribute('aria-autocomplete', 'list'); - this.input.setAttribute('autocomplete', 'off'); - this.input.setAttribute('data-drupal-autocomplete-input', ''); - this.input.setAttribute('aria-owns', listboxId); - this.input.setAttribute('role', 'combobox'); - this.input.setAttribute('aria-expanded', 'false'); - this.ul = document.createElement('ul'); - this.ul.setAttribute('role', 'listbox'); - this.ul.setAttribute('data-drupal-autocomplete-list', ''); - this.ul.setAttribute('id', listboxId); - this.ul.setAttribute('hidden', ''); - this.input.parentNode.appendChild(this.ul); - this.input.classList.add('ui-autocomplete-input'); - this.ul.classList.add('ui-autocomplete'); - $(this.input).on('input', function () { - return _this.inputListener(); - }); - $(this.input).on('blur', function (e) { - return _this.blurHandler(e); - }); - $(this.input).on('keydown', function (e) { - return _this.inputKeyDown(e); - }); - $(this.ul).on('mousedown', function (e) { - return e.preventDefault(); - }); - $(this.ul).on('click', function (e) { - return _this.itemClick(e); - }); - $(this.ul).on('keydown', function (e) { - return _this.listKeyDown(e); - }); - $(this.ul).on('blur', function (e) { - return _this.blurHandler(e); - }); - } - - _createClass(_class, [{ - key: "blurHandler", - value: function blurHandler(e) { - if (this.preventClose) { - this.preventClose = false; - e.preventDefault(); - } else { - this.close(); - } - } - }, { - key: "listKeyDown", - value: function listKeyDown(e) { - if (!this.ul.contains(document.activeElement) || e.ctrlKey || e.altKey || e.metaKey || e.keyCode === this.keyCode.TAB) { - return; - } - - 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.preventClose = 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) { - previousItem.focus(); - } - } - }, { - key: "focusNext", - value: function focusNext() { - this.preventClose = true; - 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) { - nextItem.focus(); - } - } - }, { - 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.preventClose = true; - this.ul.querySelector('li').focus(); - } - } - } - }, { - key: "itemClick", - value: function itemClick(e) { - var li = e.target; - - if (li && e.button === 0) { - this.replaceInputValue(li.textContent); - e.preventDefault(); - this.close(); - } - } - }, { - key: "replaceInputValue", - value: function replaceInputValue(item) { - var cardinality = this.getCardinality(); - var numItems = this.splitValues().length; - var separator = numItems < cardinality || parseInt(cardinality, 10) === 0 ? ',' : ''; - var before = this.input.value.match(/^.+,\s*|/)[0]; - this.input.value = "".concat(before).concat(item).concat(separator); - } - }, { - key: "inputListener", - value: function inputListener() { - var _this2 = this; - - var inputId = this.input.getAttribute('id'); - var searchTerm = this.extractLastEntityReference(); - - 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(); - this.announceSuggestionCount(this.ul.children.length); - } else { - var apiUrl = this.input.getAttribute('data-autocomplete-path'); - this.input.classList.add('ui-autocomplete-loading'); - var xhr = new XMLHttpRequest(); - xhr.open('GET', "".concat(apiUrl, "?q=").concat(searchTerm)); - - xhr.onload = function () { - _this2.input.classList.remove('ui-autocomplete-loading'); - - if (xhr.status === 200) { - var results = JSON.parse(xhr.response); - _this2.suggestionItems = results; - - _this2.displayResults(); - - _this2.cache[inputId][searchTerm] = results; - - _this2.announceSuggestionCount(results.length); - } - }; - - xhr.send(); - } - } - } - }, { - key: "displayResults", - value: function displayResults() { - var _this3 = this; - - var announce = Drupal.announce; - var typed = this.extractLastEntityReference(); - - if (typed.length >= this.options.minChars && this.suggestionItems.length > 0) { - this.ul.innerHTML = ''; - this.suggestions = this.suggestionItems.filter(function (item) { - return _this3.filterResults(item, typed); - }); - - if (this.sort !== false) { - this.sortSuggestions(); - } - - this.suggestions = this.suggestions.slice(0, this.options.maxItems); - this.suggestions.forEach(function (suggestion, index) { - _this3.ul.appendChild(_this3.suggestionItem(suggestion, index)); - }); - - if (this.ul.children.length === 0) { - this.close(); - announce(Drupal.t('No results found')); - } else { - this.open(); - this.announceSuggestionCount(this.ul.children.length); - } - } else { - this.close(); - announce(Drupal.t('No results found')); - } - } - }, { - 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 _this4 = this; - - var li = document.createElement('li'); - li.innerHTML = "".concat(suggestion.value.trim(), ""); - 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 _this4.blurHandler(e); - }; - - return li; - } - }, { - key: "open", - value: function open() { - this.input.setAttribute('aria-expanded', 'true'); - this.ul.removeAttribute('hidden'); - this.isOpened = true; - this.ul.style.minWidth = "".concat(this.input.offsetWidth - 4, "px"); - - if (!this.hasOwnProperty('popper')) { - this.initPopper(); - } else { - this.popper.forceUpdate(); - } - } - }, { - key: "close", - value: function close() { - this.input.setAttribute('aria-expanded', 'false'); - this.ul.setAttribute('hidden', ''); - this.isOpened = false; - } - }, { - key: "extractLastEntityReference", - value: function extractLastEntityReference() { - 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 === ',' && !quote) { - result.push(current.trim()); - current = ''; - } else { - current += character; - } - } - - if (value.length > 0) { - result.push(current.trim()); - } - - return result; - } - }, { - key: "announceSuggestionCount", - value: function announceSuggestionCount(count) { - var announce = Drupal.announce, - formatPlural = Drupal.formatPlural; - var maxItems = this.options.maxItems; - var pluralMessage = maxItems === count ? 'There are at least @count results available. Type additional characters to refine your search.' : 'There are @count results available.'; - var message = formatPlural(count, 'There is one result available.', pluralMessage); - announce(Drupal.t(message), 'assertive'); - } - }, { - key: "filterResults", - value: function filterResults(suggestion, typed) { - var firstCharacterDenylist = this.options.firstCharacterDenylist; - var cardinality = this.getCardinality(); - var suggestionValue = suggestion.value; - var currentValues = this.splitValues(); - var inputSpecificFirstCharDenylist = this.input.hasAttribute('data-autocomplete-first-character-denylist') ? this.input.getAttribute('data-autocomplete-first-character-denylist') : ''; - - if (firstCharacterDenylist.indexOf(typed[0]) !== -1 || inputSpecificFirstCharDenylist.indexOf(typed[0]) !== -1 || currentValues.indexOf(suggestionValue) !== -1 || cardinality > 0 && currentValues.length > cardinality) { - return false; - } - - return RegExp(this.extractLastEntityReference().trim().replace(/[-\\^$*+?.()|[\]{}]/g, '\\$&'), 'i').test(suggestionValue); - } - }, { - key: "getCardinality", - value: function getCardinality() { - var wrapper = this.input.closest('[data-autocomplete-cardinality]'); - return wrapper ? wrapper.getAttribute('data-autocomplete-cardinality') : -1; - } - }, { - key: "initPopper", - value: function initPopper() { - this.popper = Popper.createPopper(this.input, this.ul, { - placement: 'bottom-start', - modifiers: [{ - name: 'flip', - options: { - fallbackPlacements: [] - } - }] - }); - } - }]); - - return _class; - }(); - - Drupal.Autocomplete.instances = []; - Drupal.behaviors.autocomplete = { - attach: function attach(context) { - var options = drupalSettings.autocompleteOptions; - var $autoCompleteInputs = $(context).find('input.form-autocomplete').once('autocomplete-init'); - Drupal.Autocomplete.instances = Drupal.Autocomplete.instances.concat($autoCompleteInputs.map(function (index, autocompleteInput) { - return new Drupal.Autocomplete(autocompleteInput, options); - }).get()); - } - }; -})(jQuery, Drupal, drupalSettings, Popper); \ No newline at end of file diff --git a/core/modules/d9_autocomplete/js/drupalautocomplete.es6.js b/core/modules/d9_autocomplete/js/drupalautocomplete.es6.js new file mode 100644 index 0000000000..a3ab04f2ca --- /dev/null +++ b/core/modules/d9_autocomplete/js/drupalautocomplete.es6.js @@ -0,0 +1,694 @@ +/** + * @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 { + 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; + const listboxId = `autocomplete-listbox-${this.count}`; + + const defaultOptions = { + firstCharacterDenylist: ',', + minChars: 1, + maxItems: 10, + sort: false, + path: false, + list: [], + cardinality: 1, + inputClass: '', + ulClass: '', + itemClass: '', + loadingClass: 'drupal-autocomplete-loading', + separatorChar: ',', + liveRegion: true, + listZindex: 100, + 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(), + }; + + this.preventClose = false; + this.isOpened = false; + this.cache = []; + this.suggestionItems = []; + this.hasAnnouncedOnce = false; + + // Create a div that will wrap the input and suggestion list. + this.wrapper = document.createElement('div'); + this.wrapper.setAttribute('data-drupal-autocomplete-wrapper', ''); + input.parentNode.appendChild(this.wrapper); + this.wrapper.appendChild(input); + + // 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', 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)); + } + + // Create the list that will display suggestions. + this.ul = document.createElement('ul'); + this.ul.setAttribute('role', 'listbox'); + this.ul.setAttribute('data-drupal-autocomplete-list', ''); + this.ul.setAttribute('id', 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)); + } + + // When applicable, create a live region for announcing suggestion results + // to assistive technology. + this.liveRegion = null; + if (this.options.liveRegion === 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); + } + + // 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'); + } + + /** + * 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') || {}; + + // 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 (this.preventClose) { + this.preventClose = false; + e.preventDefault(); + } else { + this.close(); + } + } + + /** + * 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; + } + + 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.preventClose = 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.preventClose = true; + previousItem.focus(); + } else { + this.input.focus(); + this.close(); + } + } + + /** + * Moves focus to the next list item. + */ + focusNext() { + this.preventClose = true; + 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.preventClose = true; + nextItem.focus(); + } + } + + /** + * 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.preventClose = true; + this.ul.querySelector('li').focus(); + } + } + } + + /** + * 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(); + const before = this.previousItems(separator); + this.input.value = `${before}${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; + 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 regex = new RegExp(`^.+${separator}\\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.path) { + this.options.loadingClass + .split(' ') + .forEach((className) => this.input.classList.add(className)); + fetch(`${this.options.path}?q=${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 { + this.suggestionItems = this.list; + this.displayResults(); + } + } else { + this.suggestionItems = this.options.list; + this.displayResults(); + } + } + + /** + * 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.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(); + } + this.announceResults(this.ul.children.length); + } + + /** + * 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.constructor.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. + */ + static formatSuggestionItem(suggestion) { + return suggestion.value.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 === ',' && !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 a assistive tech. + * @param {number} count + * The number of suggestions. + */ + announceResults(count) { + const message = this.resultsMessage(count); + 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); + } + + /** + * 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 {event} + * The triggered event. + */ + 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/d9_autocomplete/js/drupalautocomplete.js b/core/modules/d9_autocomplete/js/drupalautocomplete.js new file mode 100644 index 0000000000..4511cd9b5a --- /dev/null +++ b/core/modules/d9_autocomplete/js/drupalautocomplete.js @@ -0,0 +1,552 @@ +/** +* 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; + var listboxId = "autocomplete-listbox-".concat(this.count); + var defaultOptions = { + firstCharacterDenylist: ',', + minChars: 1, + maxItems: 10, + sort: false, + path: false, + list: [], + cardinality: 1, + inputClass: '', + ulClass: '', + itemClass: '', + loadingClass: 'drupal-autocomplete-loading', + separatorChar: ',', + liveRegion: true, + listZindex: 100, + 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()); + this.preventClose = false; + this.isOpened = false; + this.cache = []; + this.suggestionItems = []; + this.hasAnnouncedOnce = false; + this.wrapper = document.createElement('div'); + this.wrapper.setAttribute('data-drupal-autocomplete-wrapper', ''); + input.parentNode.appendChild(this.wrapper); + this.wrapper.appendChild(input); + this.input.setAttribute('aria-autocomplete', 'list'); + this.input.setAttribute('autocomplete', 'off'); + this.input.setAttribute('data-drupal-autocomplete-input', ''); + this.input.setAttribute('aria-owns', 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 _this.input.classList.add(className); + }); + } + + this.ul = document.createElement('ul'); + this.ul.setAttribute('role', 'listbox'); + this.ul.setAttribute('data-drupal-autocomplete-list', ''); + this.ul.setAttribute('id', 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 _this.ul.classList.add(className); + }); + } + + this.liveRegion = null; + + if (this.options.liveRegion === 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); + } + + 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: "attributesToOptions", + value: function attributesToOptions() { + var options = {}; + var dataAutocompleteAttributeOptions = 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) { + if (this.preventClose) { + this.preventClose = false; + e.preventDefault(); + } else { + this.close(); + } + } + }, { + key: "listKeyDown", + value: function listKeyDown(e) { + if (!this.ul.contains(document.activeElement) || e.ctrlKey || e.altKey || e.metaKey || e.keyCode === this.keyCode.TAB) { + return; + } + + 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.preventClose = 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.preventClose = true; + previousItem.focus(); + } else { + this.input.focus(); + this.close(); + } + } + }, { + key: "focusNext", + value: function focusNext() { + this.preventClose = true; + 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.preventClose = true; + nextItem.focus(); + } + } + }, { + 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.preventClose = true; + this.ul.querySelector('li').focus(); + } + } + } + }, { + 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(); + var before = this.previousItems(separator); + this.input.value = "".concat(before).concat(item); + } + }, { + key: "separator", + value: function separator() { + var cardinality = this.options.cardinality; + var numItems = this.splitValues().length; + return numItems < cardinality || parseInt(cardinality, 10) <= 0 ? this.options.separatorChar : ''; + } + }, { + key: "previousItems", + value: function previousItems(separator) { + var regex = new RegExp("^.+".concat(separator, "\\s*|")); + var match = this.input.value.match(regex)[0]; + return match && match.length > 0 ? "".concat(match.trim(), " ") : ''; + } + }, { + key: "inputListener", + value: function inputListener() { + var _this2 = 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.path) { + this.options.loadingClass.split(' ').forEach(function (className) { + return _this2.input.classList.add(className); + }); + fetch("".concat(this.options.path, "?q=").concat(searchTerm)).then(function (response) { + return response.json(); + }).then(function (results) { + _this2.options.loadingClass.split(' ').forEach(function (className) { + return _this2.input.classList.remove(className); + }); + + _this2.suggestionItems = results; + + _this2.displayResults(); + + _this2.cache[inputId][searchTerm] = results; + }); + } else { + this.suggestionItems = this.list; + this.displayResults(); + } + } else { + this.suggestionItems = this.options.list; + this.displayResults(); + } + } + }, { + key: "displayResults", + value: function displayResults() { + var _this3 = 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 _this3.filterResults(item, typed); + }); + + if (this.sort !== false) { + this.sortSuggestions(); + } + + this.suggestions = this.suggestions.slice(0, this.options.maxItems); + this.suggestions.forEach(function (suggestion, index) { + _this3.ul.appendChild(_this3.suggestionItem(suggestion, index)); + }); + } + + if (this.ul.children.length === 0) { + this.close(); + } else { + this.open(); + } + + this.announceResults(this.ul.children.length); + } + }, { + 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 _this4 = this; + + var li = document.createElement('li'); + li.innerHTML = this.constructor.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 _this4.blurHandler(e); + }; + + return li; + } + }, { + 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 === ',' && !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); + + 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: "destroy", + value: function destroy() { + var _this5 = 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(_this5.events[elementName]).forEach(function (eventName) { + _this5[elementName].removeEventListener(eventName, _this5.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); + } + }], [{ + key: "formatSuggestionItem", + value: function formatSuggestionItem(suggestion) { + return suggestion.value.trim(); + } + }]); + + return DrupalAutocomplete; +}(); + +window.DrupalAutocomplete = DrupalAutocomplete; \ No newline at end of file diff --git a/core/themes/claro/css/components/jquery.ui/theme.css b/core/themes/claro/css/components/jquery.ui/theme.css index 91c76bc080..b5c5f86461 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-wrapper: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-wrapper: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-wrapper: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; }