Skip to content

Commit 8d655b1

Browse files
committed
SolidOS/profile-pane#422 Refactor combobox behaviour
The previous implementation was a bit clunky with the autocomplete and default options list after opening with async options. It also split the concern for watching value changes in two parts (onValueChanged vs willUpdate). This implementation should be more reliable and also supports fallback options and the "change" event.
1 parent 9e8e1d0 commit 8d655b1

3 files changed

Lines changed: 77 additions & 59 deletions

File tree

src/components/combobox/Combobox.ts

Lines changed: 70 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@ import styles from './Combobox.styles.css'
1515
class AsyncOptionsInfo extends Error {}
1616

1717
export type ComboboxOptionData = {
18-
value: string;
19-
label: string | TemplateResult;
18+
value: unknown;
19+
label: string;
20+
template?: TemplateResult;
2021
selectable?: boolean;
2122
}
2223

24+
export type ComboboxChangeEvent = CustomEvent<{ option: ComboboxOptionData }>
25+
2326
export type AsyncComboboxOptionsProvider = (query: string) => Promise<ComboboxOptionData[]>
2427

2528
export function defineAsyncComboboxOptionsProvider<T extends AsyncComboboxOptionsProvider> (provider: T): T {
@@ -37,7 +40,7 @@ export default class Combobox extends WebComponent {
3740
@property({ type: String, reflect: true })
3841
accessor name = ''
3942

40-
@property({ type: String })
43+
@property()
4144
accessor value = ''
4245

4346
@property({ type: String, reflect: true })
@@ -64,15 +67,15 @@ export default class Combobox extends WebComponent {
6467
@property({ type: Function })
6568
accessor asyncOptionsProvider: AsyncComboboxOptionsProvider | null = null
6669

70+
@property({ type: Array })
71+
accessor optionsFallback: ComboboxOptionData[] | null = null
72+
6773
@query('input')
6874
private accessor inputElement: HTMLInputElement | null = null
6975

7076
@state()
7177
private accessor filter = ''
7278

73-
@state()
74-
private accessor debouncedFilter = ''
75-
7679
@state()
7780
private accessor displayValue = ''
7881

@@ -95,29 +98,23 @@ export default class Combobox extends WebComponent {
9598
new InputTrait(this, {
9699
getInputElement: () => this.inputElement,
97100
getInternals: () => this.getInternals(),
98-
onValueChanged: (value) => {
99-
this.filter = value.toLowerCase()
100-
this.displayValue = value
101-
102-
this.updateFilter(value.toLowerCase())
103-
104-
const options = this.getFilteredOptions()
105-
106-
if (options.length === 0) {
107-
this.hide()
108-
return
109-
}
110-
111-
if (this.open) {
112-
this.activeIndex = this.getInitialActiveIndex(options)
113-
}
114-
},
115101
})
116102
)
117103

118104
this.listboxId = `listbox-${this.inputTrait.inputId}`
119105
}
120106

107+
get selectedOption (): ComboboxOptionData | undefined {
108+
if (!this.value) {
109+
return
110+
}
111+
112+
return {
113+
label: this.displayValue,
114+
value: this.value,
115+
}
116+
}
117+
121118
disconnectedCallback () {
122119
super.disconnectedCallback()
123120

@@ -127,13 +124,21 @@ export default class Combobox extends WebComponent {
127124
protected willUpdate (changedProperties: Map<string, unknown>) {
128125
super.willUpdate(changedProperties)
129126

130-
if (changedProperties.has('value') && this.displayValue === '') {
131-
this.displayValue = this.value
132-
}
133-
134127
if (changedProperties.has('asyncOptionsUrl') || changedProperties.has('asyncOptionsProvider')) {
135128
this.updateAsyncOptionsTask()
136129
}
130+
131+
if (changedProperties.has('value')) {
132+
const options = this.getFilteredOptions()
133+
const option = options.find((option) => option.value === this.value) ?? this.optionsFallback?.find((option) => option.value === this.value)
134+
const optionLabel = option?.selectable !== false && option?.label
135+
136+
this.updateDisplayValue(optionLabel || this.value)
137+
138+
if (this.open) {
139+
this.activeIndex = this.getInitialActiveIndex(options)
140+
}
141+
}
137142
}
138143

139144
protected render () {
@@ -176,7 +181,7 @@ export default class Combobox extends WebComponent {
176181
.value=${this.displayValue}
177182
@keydown=${this.onInputKeyDown}
178183
@focus=${this.onInputFocus}
179-
@input=${() => this.selectOnly ? this.updateFilter(this.inputElement?.value ?? '') : this.inputTrait.onInput()}
184+
@input=${() => this.selectOnly ? this.updateDisplayValue(this.inputElement?.value ?? '') : this.inputTrait.onInput()}
180185
/>
181186
<icon-lucide-chevron-down></icon-lucide-chevron-down>
182187
</div>
@@ -201,9 +206,9 @@ export default class Combobox extends WebComponent {
201206
data-active=${index === this.activeIndex || nothing}
202207
@mousemove=${() => this.setActiveIndex(index)}
203208
>
204-
${option.label}
209+
${option.template ?? option.label}
205210
</div>`
206-
: option.label
211+
: option.template ?? option.label
207212
})}
208213
</div>
209214
</wa-popup>
@@ -212,12 +217,14 @@ export default class Combobox extends WebComponent {
212217

213218
private getFilteredOptions (): ComboboxOptionData[] {
214219
if (this.asyncOptionsTask) {
215-
const options = this.asyncOptionsTask.render({
220+
return this.asyncOptionsTask.render({
221+
initial: () => this.optionsFallback ?? [],
216222
complete: (options) => options,
217223
pending: () => [
218224
{
219225
value: '',
220-
label: html`<div class="loading-async-options"><icon-svg-spinners-3-dots-fade></icon-svg-spinners-3-dots-fade></div>`,
226+
label: 'Loading...',
227+
template: html`<div class="loading-async-options"><icon-svg-spinners-3-dots-fade></icon-svg-spinners-3-dots-fade></div>`,
221228
selectable: false
222229
} as const,
223230
],
@@ -228,14 +235,13 @@ export default class Combobox extends WebComponent {
228235
return [
229236
{
230237
value: '',
231-
label: html`<div class="async-options-message async-options-message--${isError ? 'error' : 'info'}">${message}</div>`,
238+
label: message,
239+
template: html`<div class="async-options-message async-options-message--${isError ? 'error' : 'info'}">${message}</div>`,
232240
selectable: false
233241
} as const,
234242
]
235243
},
236244
})
237-
238-
return options ?? []
239245
}
240246

241247
return this.getOptionsFromDOM().filter((option) =>
@@ -284,13 +290,17 @@ export default class Combobox extends WebComponent {
284290
this.activeIndex = index
285291
}
286292

287-
private updateFilter (value: string, options: { debounce?: boolean } = {}) {
288-
this.filter = value.toLowerCase()
293+
private updateDisplayValue (value: unknown) {
294+
this.displayValue = String(value)
295+
296+
if (this.open) {
297+
const filter = this.displayValue.toLowerCase()
289298

290-
if (options.debounce ?? true) {
291-
this.updateDebouncedFilter(this.filter)
292-
} else {
293-
this.debouncedFilter = this.filter
299+
if (this.asyncOptionsTask) {
300+
this.updateDebouncedFilter(filter)
301+
} else {
302+
this.filter = filter
303+
}
294304
}
295305
}
296306

@@ -333,10 +343,10 @@ export default class Combobox extends WebComponent {
333343

334344
return results.map((result) => ({
335345
label: String(Object(result)[labelField]),
336-
value: String(Object(result)[valueField]),
346+
value: Object(result)[valueField],
337347
}))
338348
},
339-
() => [this.debouncedFilter]
349+
() => [this.filter]
340350
)
341351
}
342352

@@ -365,20 +375,24 @@ export default class Combobox extends WebComponent {
365375
return
366376
}
367377

378+
this.filter = ''
368379
this.open = false
369380
this.activeIndex = -1
370381
this.removeOpenListeners()
371-
this.updateFilter('', { debounce: false })
382+
this.updateDebouncedFilter.cancel()
372383
}
373384

374385
private selectOption (option: ComboboxOptionData) {
375-
this.inputTrait.setValue(option.value)
386+
const previousValue = this.value
376387

377-
this.displayValue =
378-
typeof option.label === 'string' ? option.label : option.value
379388
this.hide()
389+
this.inputTrait.setValue(option.value)
380390
this.inputElement?.focus({ preventScroll: true })
381-
this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))
391+
this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true, detail: { option } }))
392+
393+
if (previousValue === this.value) {
394+
this.updateDisplayValue(option.label)
395+
}
382396
}
383397

384398
private scrollActiveOptionIntoView () {
@@ -442,6 +456,10 @@ export default class Combobox extends WebComponent {
442456
}
443457

444458
private onAnchorMouseDown (event: MouseEvent) {
459+
if (event.target === this.inputElement) {
460+
return
461+
}
462+
445463
event.preventDefault()
446464
this.inputElement?.focus({ preventScroll: true })
447465
}
@@ -500,6 +518,10 @@ export default class Combobox extends WebComponent {
500518
case 'Tab':
501519
this.hide()
502520
break
521+
default:
522+
if (!this.open) {
523+
this.show()
524+
}
503525
}
504526
}
505527

src/components/combobox/index.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import Combobox, { defineAsyncComboboxOptionsProvider } from './Combobox'
2-
import type { AsyncComboboxOptionsProvider, ComboboxOptionData } from './Combobox'
1+
import Combobox from './Combobox'
32

4-
export { Combobox, defineAsyncComboboxOptionsProvider }
5-
export type { AsyncComboboxOptionsProvider, ComboboxOptionData }
3+
export * from './Combobox'
4+
export { Combobox }
65
export default Combobox

src/lib/components/traits/InputTrait.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,12 @@ export type InputTraitTarget = WebComponent & {
99
name: string;
1010
label: string;
1111
required: boolean;
12-
value: string;
12+
value: unknown;
1313
}
1414

1515
export interface InputTraitConfig {
1616
getInputElement(): HTMLInputElement | HTMLSelectElement | null
1717
getInternals(): ElementInternals
18-
onValueChanged?: (value: string) => void
1918
}
2019

2120
export default class InputTrait implements WebComponentTrait {
@@ -32,7 +31,7 @@ export default class InputTrait implements WebComponentTrait {
3231
}
3332

3433
firstUpdated () {
35-
this.config.getInternals().setFormValue(this.target.value)
34+
this.config.getInternals().setFormValue(String(this.target.value))
3635
this.updateValidity()
3736
}
3837

@@ -46,7 +45,6 @@ export default class InputTrait implements WebComponentTrait {
4645
this.target.value = ''
4746
this.config.getInternals().setFormValue('')
4847
this.updateValidity()
49-
this.config.onValueChanged?.('')
5048
}
5149

5250
renderLabel () {
@@ -63,12 +61,11 @@ export default class InputTrait implements WebComponentTrait {
6361
this.config.getInternals().form?.requestSubmit()
6462
}
6563

66-
setValue (value: string) {
64+
setValue (value: unknown) {
6765
this.target.value = value
6866

69-
this.config.getInternals().setFormValue(this.target.value)
67+
this.config.getInternals().setFormValue(String(this.target.value))
7068
this.target.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true }))
71-
this.config.onValueChanged?.(this.target.value)
7269
}
7370

7471
private updateValidity () {

0 commit comments

Comments
 (0)