Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detangle autocompletion, selection, and filtering #111

Merged
merged 11 commits into from
Mar 15, 2024
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export default class HwComboboxController extends Concerns(...concerns) {

if (inputType && inputType !== "hw:lockInSelection") {
if (delay) await sleep(delay)
this._commitFilter({ inputType })
this._selectBasedOnQuery({ inputType })
} else {
this._preselectOption()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@ Combobox.AsyncLoading = Base => class extends Base {
get _isAsync() {
return this.hasAsyncSrcValue
}

get _isSync() {
return !this._isAsync
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ Combobox.Autocomplete = Base => class extends Base {
}
}

_autocompleteWith(option, { force }) {
if (!this._autocompletesInline && !force) return
_replaceFullQueryWithAutocompletedValue(option) {
const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)

this._fullQuery = autocompletedValue
this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length)
}

_autocompleteMissingPortion(option) {
const typedValue = this._typedQuery
const autocompletedValue = option.getAttribute(this.autocompletableAttributeValue)

if (force) {
this._fullQuery = autocompletedValue
this._actingCombobox.setSelectionRange(autocompletedValue.length, autocompletedValue.length)
} else if (startsWith(autocompletedValue, typedValue)) {
if (this._autocompletesInline && startsWith(autocompletedValue, typedValue)) {
this._fullQuery = autocompletedValue
this._actingCombobox.setSelectionRange(typedValue.length, autocompletedValue.length)
}
Expand Down
11 changes: 8 additions & 3 deletions app/assets/javascripts/hw_combobox/models/combobox/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@ import Combobox from "hw_combobox/models/combobox/base"
import { dispatch } from "hw_combobox/helpers"

Combobox.Events = Base => class extends Base {
_dispatchSelectionEvent({ isNew }) {
dispatch("hw-combobox:selection", { target: this.element, detail: { ...this._eventableDetails, isNew } })
_dispatchSelectionEvent({ isNewAndAllowed, previousValue }) {
if (previousValue !== this._value) {
dispatch("hw-combobox:selection", {
target: this.element,
detail: { ...this._eventableDetails, isNewAndAllowed, previousValue }
})
}
}

_dispatchClosedEvent() {
Expand All @@ -12,7 +17,7 @@ Combobox.Events = Base => class extends Base {

get _eventableDetails() {
return {
value: this.hiddenFieldTarget.value,
value: this._value,
display: this._fullQuery,
query: this._typedQuery,
fieldName: this.hiddenFieldTarget.name,
Expand Down
41 changes: 19 additions & 22 deletions app/assets/javascripts/hw_combobox/models/combobox/filtering.js
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@

import Combobox from "hw_combobox/models/combobox/base"
import { applyFilter, debounce, isDeleteEvent, unselectedPortion } from "hw_combobox/helpers"
import { applyFilter, debounce, unselectedPortion } from "hw_combobox/helpers"
import { get } from "hw_combobox/vendor/requestjs"

Combobox.Filtering = Base => class extends Base {
filter(event) {
if (this._isAsync) {
this._debouncedFilterAsync(event)
filterAndSelect(event) {
this._filter(event)

if (this._isSync) {
this._selectBasedOnQuery(event)
} else {
this._filterSync(event)
// noop, async selection is handled by stimulus callbacks
}

this._actingCombobox.toggleAttribute("data-queried", this._isQueried)
}

_initializeFiltering() {
this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this))
}

_filter(event) {
if (this._isAsync) {
this._debouncedFilterAsync(event)
} else {
this._filterSync()
}

this._actingCombobox.toggleAttribute("data-queried", this._isQueried)
}

_debouncedFilterAsync(event) {
this._filterAsync(event)
}
Expand All @@ -32,27 +42,14 @@ Combobox.Filtering = Base => class extends Base {
await get(this.asyncSrcValue, { responseKind: "turbo-stream", query })
}

_filterSync(event) {
_filterSync() {
this.open()
this._allOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }))
this._commitFilter(event)
}

_commitFilter(event) {
if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
this._selectNew()
} else if (isDeleteEvent(event)) {
this._deselect()
} else if (event.inputType === "hw:lockInSelection") {
this._select(this._ensurableOption)
} else if (this._isOpen) {
this._select(this._visibleOptionElements[0])
}
}

_clearQuery() {
this._fullQuery = ""
this.filter({ inputType: "deleteContentBackward" })
this.filterAndSelect({ inputType: "deleteContentBackward" })
}

get _isQueried() {
Expand Down
16 changes: 12 additions & 4 deletions app/assets/javascripts/hw_combobox/models/combobox/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@ import Combobox from "hw_combobox/models/combobox/base"
import { visible } from "hw_combobox/helpers"

Combobox.Options = Base => class extends Base {
_resetOptions() {
this._deselect()
this.hiddenFieldTarget.name = this.originalNameValue
_resetOptionsSilently() {
this._resetOptions(this._deselect.bind(this))
}

_resetOptionsAndNotify() {
this._resetOptions(this._deselectAndNotify.bind(this))
}

_resetOptions(deselectionStrategy) {
this._setName(this.originalNameValue)
deselectionStrategy()
}

get _allowNew() {
Expand Down Expand Up @@ -32,7 +40,7 @@ Combobox.Options = Base => class extends Base {
}

get _isUnjustifiablyBlank() {
const valueIsMissing = !this.hiddenFieldTarget.value
const valueIsMissing = !this._value
const noBlankOptionSelected = !this._selectedOptionElement

return valueIsMissing && noBlankOptionSelected
Expand Down
139 changes: 95 additions & 44 deletions app/assets/javascripts/hw_combobox/models/combobox/selection.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import Combobox from "hw_combobox/models/combobox/base"
import { wrapAroundAccess } from "hw_combobox/helpers"
import { wrapAroundAccess, isDeleteEvent } from "hw_combobox/helpers"

Combobox.Selection = Base => class extends Base {
selectOptionOnClick(event) {
this.filter(event)
this._select(event.currentTarget, { forceAutocomplete: true })
this._forceSelectionAndFilter(event.currentTarget, event)
this.close()
}

Expand All @@ -14,91 +13,143 @@ Combobox.Selection = Base => class extends Base {
}
}

_select(option, { forceAutocomplete = false } = {}) {
this._resetOptions()

if (option) {
this._autocompleteWith(option, { force: forceAutocomplete })
this._commitSelection(option, { selected: true })
this._markValid()
} else {
_selectBasedOnQuery(event) {
if (this._shouldTreatAsNewOptionForFiltering(!isDeleteEvent(event))) {
this._selectNew()
} else if (isDeleteEvent(event)) {
this._deselect()
} else if (event.inputType === "hw:lockInSelection" && this._ensurableOption) {
this._selectAndAutocompleteMissingPortion(this._ensurableOption)
} else if (this._isOpen && this._visibleOptionElements[0]) {
this._selectAndAutocompleteMissingPortion(this._visibleOptionElements[0])
} else if (this._isOpen) {
this._resetOptionsAndNotify()
this._markInvalid()
} else {
// When selecting from an async dialog listbox: selection is forced, the listbox is filtered,
// and the dialog is closed. Filtering ends with an `endOfOptionsStream` target connected
// to the now invisible combobox, which is now closed because Turbo waits for "nextRepaint"
// before rendering turbo streams. This ultimately calls +_selectBasedOnQuery+. We do want
// to call +_selectBasedOnQuery+ in this case to account for e.g. selection of
// new options. But we will noop here if it's none of the cases checked above.
}
}

_commitSelection(option, { selected }) {
this._markSelected(option, { selected })
_select(option, autocompleteStrategy) {
const previousValue = this._value

if (selected) {
this.hiddenFieldTarget.value = option.dataset.value
option.scrollIntoView({ block: "nearest" })
}
this._resetOptionsSilently()

this._dispatchSelectionEvent({ isNew: false })
autocompleteStrategy(option)

this._setValue(option.dataset.value)
this._markSelected(option)
this._markValid()
this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue })

option.scrollIntoView({ block: "nearest" })
}

_markSelected(option, { selected }) {
if (this.hasSelectedClass) {
option.classList.toggle(this.selectedClass, selected)
}
_selectNew() {
const previousValue = this._value

option.setAttribute("aria-selected", selected)
this._setActiveDescendant(selected ? option.id : "")
this._resetOptionsSilently()
this._setValue(this._fullQuery)
this._setName(this.nameWhenNewValue)
this._markValid()
this._dispatchSelectionEvent({ isNewAndAllowed: true, previousValue: previousValue })
}

_deselect() {
const option = this._selectedOptionElement
const previousValue = this._value

if (option) this._commitSelection(option, { selected: false })
if (this._selectedOptionElement) {
this._markNotSelected(this._selectedOptionElement)
}

this.hiddenFieldTarget.value = null
this._setValue(null)
this._setActiveDescendant("")

if (!option) this._dispatchSelectionEvent({ isNew: false })
return previousValue
}

_selectNew() {
this._resetOptions()
this.hiddenFieldTarget.value = this._fullQuery
this.hiddenFieldTarget.name = this.nameWhenNewValue
this._markValid()
_deselectAndNotify() {
const previousValue = this._deselect()
this._dispatchSelectionEvent({ isNewAndAllowed: false, previousValue: previousValue })
}

_forceSelectionAndFilter(option, event) {
this._forceSelectionWithoutFiltering(option)
this._filter(event)
}

this._dispatchSelectionEvent({ isNew: true })
_forceSelectionWithoutFiltering(option) {
this._selectAndReplaceFullQuery(option)
}

_selectIndex(index) {
const option = wrapAroundAccess(this._visibleOptionElements, index)
this._select(option, { forceAutocomplete: true })
this._forceSelectionWithoutFiltering(option)
}

_preselectOption() {
if (this._hasValueButNoSelection && this._allOptions.length < 100) {
const option = this._allOptions.find(option => {
return option.dataset.value === this.hiddenFieldTarget.value
return option.dataset.value === this._value
})

if (option) this._markSelected(option, { selected: true })
if (option) this._markSelected(option)
}
}

_selectAndReplaceFullQuery(option) {
this._select(option, this._replaceFullQueryWithAutocompletedValue.bind(this))
}

_selectAndAutocompleteMissingPortion(option) {
this._select(option, this._autocompleteMissingPortion.bind(this))
}

_lockInSelection() {
if (this._shouldLockInSelection) {
this._select(this._ensurableOption, { forceAutocomplete: true })
this.filter({ inputType: "hw:lockInSelection" })
this._forceSelectionAndFilter(this._ensurableOption, { inputType: "hw:lockInSelection" })
}
}

if (this._isUnjustifiablyBlank) {
this._deselect()
this._clearQuery()
}
_markSelected(option) {
if (this.hasSelectedClass) option.classList.add(this.selectedClass)
option.setAttribute("aria-selected", true)
this._setActiveDescendant(option.id)
}

_markNotSelected(option) {
if (this.hasSelectedClass) option.classList.remove(this.selectedClass)
option.removeAttribute("aria-selected")
this._removeActiveDescendant()
}

_setActiveDescendant(id) {
this._forAllComboboxes(el => el.setAttribute("aria-activedescendant", id))
}

_removeActiveDescendant() {
this._setActiveDescendant("")
}

_setValue(value) {
this.hiddenFieldTarget.value = value
}

_setName(value) {
this.hiddenFieldTarget.name = value
}

get _value() {
return this.hiddenFieldTarget.value
}

get _hasValueButNoSelection() {
return this.hiddenFieldTarget.value && !this._selectedOptionElement
return this._value && !this._selectedOptionElement
}

get _shouldLockInSelection() {
Expand Down
Loading