MDL-70075 core: Autocomplete selection should always have an active item
authorAndrew Nicols <andrew@nicols.co.uk>
Wed, 4 Nov 2020 03:24:04 +0000 (11:24 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Thu, 5 Nov 2020 01:05:38 +0000 (09:05 +0800)
Ensure that there is always one active element in the list of selected
autocomplete elements.

Without this we have issues beacuse clicking on the link makes the first
one active if one is not already active, and this turns a click event
into a drag event, which means that it is not deleted.

lib/amd/build/form-autocomplete.min.js
lib/amd/build/form-autocomplete.min.js.map
lib/amd/src/form-autocomplete.js

index c9f2aca..23e42cb 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js and b/lib/amd/build/form-autocomplete.min.js differ
index d4a4e03..5c1fe60 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js.map and b/lib/amd/build/form-autocomplete.min.js.map differ
index 8c357fb..f7f0f21 100644 (file)
@@ -76,10 +76,58 @@ function($, log, str, templates, notification, LoadingIcon, Aria) {
 
         // Tell the input field it has a new active descendant so the item is announced.
         selectionElement.attr('aria-activedescendant', itemId);
+        selectionElement.attr('data-active-value', element.attr('data-value'));
 
         return $.Deferred().resolve();
     };
 
+    /**
+     * Get the actively selected element from the state object.
+     *
+     * @param   {Object} state
+     * @returns {jQuery}
+     */
+    var getActiveElementFromState = function(state) {
+        var selectionRegion = $(document.getElementById(state.selectionId));
+        var activeId = selectionRegion.attr('aria-activedescendant');
+
+        if (activeId) {
+            var activeElement = $(document.getElementById(activeId));
+            if (activeElement.length) {
+                // The active descendent still exists.
+                return activeElement;
+            }
+        }
+
+        var activeValue = selectionRegion.attr('data-active-value');
+        return selectionRegion.find('[data-value="' + activeValue + '"]');
+    };
+
+    /**
+     * Update the active selection from the given state object.
+     *
+     * @param   {Object} state
+     */
+    var updateActiveSelectionFromState = function(state) {
+        var activeElement = getActiveElementFromState(state);
+        var activeValue = activeElement.attr('data-value');
+
+        var selectionRegion = $(document.getElementById(state.selectionId));
+        if (activeValue) {
+            // Find the index of the currently selected index.
+            var activeIndex = selectionRegion.find('[aria-selected=true]').index(activeElement);
+
+            if (activeIndex !== -1) {
+                activateSelection(activeIndex, state);
+                return;
+            }
+        }
+
+        // Either the active index was not set, or it could not be found.
+        // Select the first value instead.
+        activateSelection(0, state);
+    };
+
     /**
      * Update the element that shows the currently selected items.
      *
@@ -97,12 +145,6 @@ function($, log, str, templates, notification, LoadingIcon, Aria) {
         // Build up a valid context to re-render the template.
         var items = [];
         var newSelection = $(document.getElementById(state.selectionId));
-        var activeId = newSelection.attr('aria-activedescendant');
-        var activeValue = false;
-
-        if (activeId) {
-            activeValue = $(document.getElementById(activeId)).attr('data-value');
-        }
         originalSelect.children('option').each(function(index, ele) {
             if ($(ele).prop('selected')) {
                 var label;
@@ -116,23 +158,24 @@ function($, log, str, templates, notification, LoadingIcon, Aria) {
                 }
             }
         });
-        var context = $.extend({items: items}, options, state);
+
+        if (!hasItemListChanged(state, items)) {
+            M.util.js_complete(pendingKey);
+            return Promise.resolve();
+        }
+
+        state.items = items;
+
+        var context = $.extend(options, state);
         // Render the template.
         return templates.render(options.templates.items, context)
         .then(function(html, js) {
             // Add it to the page.
             templates.replaceNodeContents(newSelection, html, js);
 
-            if (activeValue !== false) {
-                // Reselect any previously selected item.
-                newSelection.children('[aria-selected=true]').each(function(index, ele) {
-                    if ($(ele).attr('data-value') === activeValue) {
-                        activateSelection(index, state);
-                    }
-                });
-            }
+            updateActiveSelectionFromState(state);
 
-            return activeValue;
+            return;
         })
         .then(function() {
             return M.util.js_complete(pendingKey);
@@ -140,6 +183,21 @@ function($, log, str, templates, notification, LoadingIcon, Aria) {
         .catch(notification.exception);
     };
 
+    /**
+     * Check whether the list of items stored in the state has changed.
+     *
+     * @param   {Object} state
+     * @param   {Array} items
+     */
+    var hasItemListChanged = function(state, items) {
+        if (state.items.length !== items.length) {
+            return true;
+        }
+
+        // Check for any items in the state items which are not present in the new items list.
+        return state.items.filter(item => items.indexOf(item) === -1).length > 0;
+    };
+
     /**
      * Notify of a change in the selection.
      *
@@ -807,6 +865,7 @@ function($, log, str, templates, notification, LoadingIcon, Aria) {
             .catch();
         });
         var selectionElement = $(document.getElementById(state.selectionId));
+
         // Handle clicks on the selected items (will unselect an item).
         selectionElement.on('click', '[role=option]', function(e) {
             var pendingPromise = addPendingJSPromise('form-autocomplete-clicks');
@@ -814,17 +873,12 @@ function($, log, str, templates, notification, LoadingIcon, Aria) {
             // Remove it from the selection.
             pendingPromise.resolve(deselectItem(options, state, $(e.currentTarget), originalSelect));
         });
+
         // When listbox is focused, focus on the first option if there is no focused option.
         selectionElement.on('focus', function() {
-            // Find the list of selections.
-            var selectionsElement = $(document.getElementById(state.selectionId));
-            // Find the active one.
-            var element = selectionsElement.children('[data-active-selection]');
-            if (!element.length) {
-                activateSelection(0, state);
-                return;
-            }
+            updateActiveSelectionFromState(state);
         });
+
         // Keyboard navigation for the selection list.
         selectionElement.on('keydown', function(e) {
             var pendingPromise = addPendingJSPromise('form-autocomplete-keydown-' + e.keyCode);
@@ -1057,7 +1111,8 @@ function($, log, str, templates, notification, LoadingIcon, Aria) {
                 inputId: 'form_autocomplete_input-' + uniqueId,
                 suggestionsId: 'form_autocomplete_suggestions-' + uniqueId,
                 selectionId: 'form_autocomplete_selection-' + uniqueId,
-                downArrowId: 'form_autocomplete_downarrow-' + uniqueId
+                downArrowId: 'form_autocomplete_downarrow-' + uniqueId,
+                items: [],
             };
 
             // Increment the unique counter so we don't get duplicates ever.