diff --git a/app/assets/javascripts/controllers/hw_combobox_controller.js b/app/assets/javascripts/controllers/hw_combobox_controller.js index d1b3101..676b338 100644 --- a/app/assets/javascripts/controllers/hw_combobox_controller.js +++ b/app/assets/javascripts/controllers/hw_combobox_controller.js @@ -1,7 +1,9 @@ import Combobox from "hw_combobox/models/combobox" -import { Concerns } from "hw_combobox/helpers" +import { Concerns, sleep } from "hw_combobox/helpers" import { Controller } from "@hotwired/stimulus" +window.HotwireComboboxStreamDelay = 0 // ms, for testing purposes + const concerns = [ Controller, Combobox.Actors, @@ -70,10 +72,12 @@ export default class HwComboboxController extends Concerns(...concerns) { } } - endOfOptionsStreamTargetConnected(element) { + async endOfOptionsStreamTargetConnected(element) { const inputType = element.dataset.inputType + const delay = window.HotwireComboboxStreamDelay if (inputType && inputType !== "hw:ensureSelection") { + if (delay) await sleep(delay) this._commitFilter({ inputType }) } else { this._preselectOption() diff --git a/app/assets/javascripts/hw_combobox/helpers.js b/app/assets/javascripts/hw_combobox/helpers.js index 24aec33..439348a 100644 --- a/app/assets/javascripts/hw_combobox/helpers.js +++ b/app/assets/javascripts/hw_combobox/helpers.js @@ -37,7 +37,7 @@ export function startsWith(string, substring) { return string.toLowerCase().startsWith(substring.toLowerCase()) } -export function debounce(fn, delay = 300) { +export function debounce(fn, delay = 150) { let timeoutId = null return (...args) => { @@ -50,3 +50,15 @@ export function debounce(fn, delay = 300) { export function isDeleteEvent(event) { return event.inputType === "deleteContentBackward" || event.inputType === "deleteWordBackward" } + +export function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +export function unselectedPortion(element) { + if (element.selectionStart === element.selectionEnd) { + return element.value + } else { + return element.value.substring(0, element.selectionStart) + } +} diff --git a/app/assets/javascripts/hw_combobox/models/combobox/autocomplete.js b/app/assets/javascripts/hw_combobox/models/combobox/autocomplete.js index 3bae8bb..b2b508c 100644 --- a/app/assets/javascripts/hw_combobox/models/combobox/autocomplete.js +++ b/app/assets/javascripts/hw_combobox/models/combobox/autocomplete.js @@ -1,5 +1,5 @@ import Combobox from "hw_combobox/models/combobox/base" -import { startsWith } from "hw_combobox/helpers" +import { startsWith, unselectedPortion } from "hw_combobox/helpers" Combobox.Autocomplete = Base => class extends Base { _connectListAutocomplete() { @@ -11,7 +11,7 @@ Combobox.Autocomplete = Base => class extends Base { _autocompleteWith(option, { force }) { if (!this._autocompletesInline && !force) return - const typedValue = this._query + const typedValue = unselectedPortion(this._actingCombobox) const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue) if (force) { diff --git a/app/assets/javascripts/hw_combobox/models/combobox/selection.js b/app/assets/javascripts/hw_combobox/models/combobox/selection.js index e5cd83d..d774707 100644 --- a/app/assets/javascripts/hw_combobox/models/combobox/selection.js +++ b/app/assets/javascripts/hw_combobox/models/combobox/selection.js @@ -14,12 +14,12 @@ Combobox.Selection = Base => class extends Base { } } - _select(option, { force = false } = {}) { + _select(option, { forceAutocomplete = false } = {}) { this._resetOptions() if (option) { this._markValid() - this._autocompleteWith(option, { force }) + this._autocompleteWith(option, { force: forceAutocomplete }) this._commitSelection(option, { selected: true }) } else { this._markInvalid() @@ -57,7 +57,7 @@ Combobox.Selection = Base => class extends Base { _selectIndex(index) { const option = wrapAroundAccess(this._visibleOptionElements, index) - this._select(option, { force: true }) + this._select(option, { forceAutocomplete: true }) } _preselectOption() { @@ -72,7 +72,7 @@ Combobox.Selection = Base => class extends Base { _ensureSelection() { if (this._shouldEnsureSelection) { - this._select(this._ensurableOption, { force: true }) + this._select(this._ensurableOption, { forceAutocomplete: true }) this.filter({ inputType: "hw:ensureSelection" }) } } diff --git a/test/system/hotwire_combobox_test.rb b/test/system/hotwire_combobox_test.rb index 5ff25f5..396fea4 100644 --- a/test/system/hotwire_combobox_test.rb +++ b/test/system/hotwire_combobox_test.rb @@ -502,6 +502,20 @@ class HotwireComboboxTest < ApplicationSystemTestCase click_away end + test "async autocomplete selections don't trample over each other" do + visit async_path + + on_slow_device delay: 0.5 do + open_combobox "#movie-field" + type_in_combobox "#movie-field", "a" + sleep 0.3 # less than the delay, more than the debounce + type_in_combobox "#movie-field", "l" + sleep 0.7 # more than the delay + + assert_equal "addin", current_selection_contents + end + end + private def open_combobox(selector) find(selector).click @@ -607,6 +621,15 @@ def on_small_screen page.current_window.resize_to *original_size end + def on_slow_device(delay:) + @on_slow_device = true + page.execute_script "window.HotwireComboboxStreamDelay = #{delay * 1000}" + yield + ensure + @on_slow_device = false + page.execute_script "window.HotwireComboboxStreamDelay = 0" + end + def tab_away find("body").send_keys(:tab) end @@ -622,4 +645,8 @@ def click_away def click_on_top_left_corner page.execute_script "document.elementFromPoint(0, 0).click()" end + + def current_selection_contents + page.evaluate_script "document.getSelection().toString()" + end end