Skip to content

Commit

Permalink
Merge pull request #106 from jlw/multiple
Browse files Browse the repository at this point in the history
Add multiple selections mode
  • Loading branch information
josefarias authored Mar 20, 2024
2 parents cec6a1b + dbdc7ca commit 659767e
Show file tree
Hide file tree
Showing 50 changed files with 1,530 additions and 183 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ These are the exceptions:
2. The escape key closes the listbox and blurs the combobox. It does not clear the combobox.
3. The listbox has wrap-around selection. That is, pressing `Up Arrow` when the user is on the first option will select the last option. And pressing `Down Arrow` when on the last option will select the first option. In paginated comboboxes, the first and last options refer to the currently available options. More options may be loaded after navigating to the last currently available option.
4. It is possible to have an unlabled combobox, as that responsibility is delegated to the implementing user.
5. There are currently [no APG guidelines](https://github.com/w3c/aria-practices/issues/1512) for a multiselect combobox. We've introduced some mechanisms to make the experience accessible, like announcing multi-selections via a live region. But we'd welcome feedback on how to make it better until official guidelines are available.

It should be noted none of the maintainers use assistive technologies in their daily lives. If you do, and you feel these exceptions are detrimental to your ability to use the component, or if you find an undocumented exception, please [open a GitHub issue](https://github.com/josefarias/hotwire_combobox/issues). We'll get it sorted.

Expand Down
27 changes: 24 additions & 3 deletions app/assets/javascripts/controllers/hw_combobox_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ window.HOTWIRE_COMBOBOX_STREAM_DELAY = 0 // ms, for testing purposes
const concerns = [
Controller,
Combobox.Actors,
Combobox.Announcements,
Combobox.AsyncLoading,
Combobox.Autocomplete,
Combobox.Dialog,
Combobox.Events,
Combobox.Filtering,
Combobox.FormField,
Combobox.Multiselect,
Combobox.Navigation,
Combobox.NewOptions,
Combobox.Options,
Expand All @@ -28,7 +30,10 @@ export default class HwComboboxController extends Concerns(...concerns) {
]

static targets = [
"announcer",
"combobox",
"chipDismisser",
"closer",
"dialog",
"dialogCombobox",
"dialogFocusTrap",
Expand All @@ -49,12 +54,14 @@ export default class HwComboboxController extends Concerns(...concerns) {
nameWhenNew: String,
originalName: String,
prefilledDisplay: String,
selectionChipSrc: String,
smallViewportMaxWidth: String
}

initialize() {
this._initializeActors()
this._initializeFiltering()
this._initializeMultiselect()
}

connect() {
Expand All @@ -79,11 +86,25 @@ export default class HwComboboxController extends Concerns(...concerns) {
const inputType = element.dataset.inputType
const delay = window.HOTWIRE_COMBOBOX_STREAM_DELAY

if (inputType && inputType !== "hw:lockInSelection") {
this._resetMultiselectionMarks()

if (inputType === "hw:multiselectSync") {
this.openByFocusing()
} else if (inputType && inputType !== "hw:lockInSelection") {
if (delay) await sleep(delay)
this._selectOnQuery({ inputType })
this._selectOnQuery(inputType)
} else {
this._preselect()
this._preselectSingle()
}
}

closerTargetConnected() {
this._closeAndBlur("hw:asyncCloser")
}

// Use +_printStack+ for debugging purposes
_printStack() {
const err = new Error()
console.log(err.stack || err.stacktrace)
}
}
16 changes: 16 additions & 0 deletions app/assets/javascripts/hw_combobox/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,19 @@ export function dispatch(eventName, { target, cancelable, detail } = {}) {

return event
}

export function nextRepaint() {
if (document.visibilityState === "hidden") {
return nextEventLoopTick()
} else {
return nextAnimationFrame()
}
}

export function nextAnimationFrame() {
return new Promise((resolve) => requestAnimationFrame(() => resolve()))
}

export function nextEventLoopTick() {
return new Promise((resolve) => setTimeout(() => resolve(), 0))
}
2 changes: 2 additions & 0 deletions app/assets/javascripts/hw_combobox/models/combobox.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import Combobox from "hw_combobox/models/combobox/base"

import "hw_combobox/models/combobox/actors"
import "hw_combobox/models/combobox/announcements"
import "hw_combobox/models/combobox/async_loading"
import "hw_combobox/models/combobox/autocomplete"
import "hw_combobox/models/combobox/dialog"
import "hw_combobox/models/combobox/events"
import "hw_combobox/models/combobox/filtering"
import "hw_combobox/models/combobox/form_field"
import "hw_combobox/models/combobox/multiselect"
import "hw_combobox/models/combobox/navigation"
import "hw_combobox/models/combobox/new_options"
import "hw_combobox/models/combobox/options"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Combobox from "hw_combobox/models/combobox/base"

Combobox.Announcements = Base => class extends Base {
_announceToScreenReader(display, action) {
this.announcerTarget.innerText = `${display} ${action}`
}
}
15 changes: 11 additions & 4 deletions app/assets/javascripts/hw_combobox/models/combobox/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,31 @@ import { dispatch } from "hw_combobox/helpers"

Combobox.Events = Base => class extends Base {
_dispatchSelectionEvent({ isNewAndAllowed, previousValue }) {
if (previousValue === this._fieldValue) return
if (previousValue === this._incomingFieldValueString) return

dispatch("hw-combobox:selection", {
dispatch("hw-combobox:selection", { // TODO: rename to preselection
target: this.element,
detail: { ...this._eventableDetails, isNewAndAllowed, previousValue }
})
}

_dispatchClosedEvent() {
dispatch("hw-combobox:closed", {
dispatch("hw-combobox:closed", { // TODO: rename to selection
target: this.element,
detail: this._eventableDetails
})
}

_dispatchRemovalEvent({ removedDisplay, removedValue }) {
dispatch("hw-combobox:removal", {
target: this.element,
detail: { ...this._eventableDetails, removedDisplay, removedValue }
})
}

get _eventableDetails() {
return {
value: this._fieldValue,
value: this._incomingFieldValueString,
display: this._fullQuery,
query: this._typedQuery,
fieldName: this._fieldName,
Expand Down
25 changes: 15 additions & 10 deletions app/assets/javascripts/hw_combobox/models/combobox/filtering.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { applyFilter, debounce, unselectedPortion } from "hw_combobox/helpers"
import { get } from "hw_combobox/vendor/requestjs"

Combobox.Filtering = Base => class extends Base {
filterAndSelect(event) {
this._filter(event)
filterAndSelect({ inputType }) {
this._filter(inputType)

if (this._isSync) {
this._selectOnQuery(event)
this._selectOnQuery(inputType)
} else {
// noop, async selection is handled by stimulus callbacks
}
Expand All @@ -18,32 +18,37 @@ Combobox.Filtering = Base => class extends Base {
this._debouncedFilterAsync = debounce(this._debouncedFilterAsync.bind(this))
}

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

this._markQueried()
}

_debouncedFilterAsync(event) {
this._filterAsync(event)
_debouncedFilterAsync(inputType) {
this._filterAsync(inputType)
}

async _filterAsync(event) {
async _filterAsync(inputType) {
const query = {
q: this._fullQuery,
input_type: event.inputType,
input_type: inputType,
for_id: this.element.dataset.asyncId
}

await get(this.asyncSrcValue, { responseKind: "turbo-stream", query })
}

_filterSync() {
this._allOptionElements.forEach(applyFilter(this._fullQuery, { matching: this.filterableAttributeValue }))
this._allFilterableOptionElements.forEach(
applyFilter(
this._fullQuery,
{ matching: this.filterableAttributeValue }
)
)
}

_clearQuery() {
Expand Down
67 changes: 61 additions & 6 deletions app/assets/javascripts/hw_combobox/models/combobox/form_field.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,74 @@
import Combobox from "hw_combobox/models/combobox/base"

Combobox.FormField = Base => class extends Base {
_setFieldValue(value) {
this.hiddenFieldTarget.value = value
get _fieldValue() {
if (this._isMultiselect) {
const currentValue = this.hiddenFieldTarget.value
const arrayFromValue = currentValue ? currentValue.split(",") : []

return new Set(arrayFromValue)
} else {
return this.hiddenFieldTarget.value
}
}

_setFieldName(value) {
this.hiddenFieldTarget.name = value
get _fieldValueString() {
if (this._isMultiselect) {
return this._fieldValueArray.join(",")
} else {
return this.hiddenFieldTarget.value
}
}

get _fieldValue() {
return this.hiddenFieldTarget.value
get _incomingFieldValueString() {
if (this._isMultiselect) {
const array = this._fieldValueArray

if (this.hiddenFieldTarget.dataset.valueForMultiselect) {
array.push(this.hiddenFieldTarget.dataset.valueForMultiselect)
}

return array.join(",")
} else {
return this.hiddenFieldTarget.value
}
}

get _fieldValueArray() {
if (this._isMultiselect) {
return Array.from(this._fieldValue)
} else {
return [ this.hiddenFieldTarget.value ]
}
}

set _fieldValue(value) {
if (this._isMultiselect) {
this.hiddenFieldTarget.dataset.valueForMultiselect = value?.replace(/,/g, "")
this.hiddenFieldTarget.dataset.displayForMultiselect = this._fullQuery
} else {
this.hiddenFieldTarget.value = value
}
}

get _hasEmptyFieldValue() {
if (this._isMultiselect) {
return this.hiddenFieldTarget.dataset.valueForMultiselect == "" ||
this.hiddenFieldTarget.dataset.valueForMultiselect == "undefined"
} else {
return this.hiddenFieldTarget.value === ""
}
}

get _hasFieldValue() {
return !this._hasEmptyFieldValue
}

get _fieldName() {
return this.hiddenFieldTarget.name
}

set _fieldName(value) {
this.hiddenFieldTarget.name = value
}
}
Loading

0 comments on commit 659767e

Please sign in to comment.