From ff33189f56ddbdc0ea3c9226478ad679634b8f90 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 7 Jul 2026 10:13:42 +0200 Subject: [PATCH 1/8] SolidOS/profile-pane#422 Implement async options loading in Combobox --- package-lock.json | 10 ++ package.json | 1 + src/components/combobox/Combobox.stories.ts | 26 +++- src/components/combobox/Combobox.styles.css | 21 +++- src/components/combobox/Combobox.ts | 133 +++++++++++++++++--- 5 files changed, 168 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index a821bce43..a57cd6505 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "dependencies": { "@awesome.me/webawesome": "^3.9.0", "@lit/context": "^1.1.6", + "@lit/task": "^1.0.3", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "escape-html": "^1.0.3", @@ -3157,6 +3158,15 @@ "@lit-labs/ssr-dom-shim": "^1.5.0" } }, + "node_modules/@lit/task": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lit/task/-/task-1.0.3.tgz", + "integrity": "sha512-1gJGJl8WON+2j0y9xfcD+XsS1rvcy3XDgsIhcdUW++yTR8ESjZW6o7dn8M8a4SZM8NnJe6ynS2cKWwsbfLOurg==", + "license": "BSD-3-Clause", + "dependencies": { + "@lit/reactive-element": "^1.0.0 || ^2.0.0" + } + }, "node_modules/@mdx-js/react": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz", diff --git a/package.json b/package.json index 6a42842a2..c0c30a0f3 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "dependencies": { "@awesome.me/webawesome": "^3.9.0", "@lit/context": "^1.1.6", + "@lit/task": "^1.0.3", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "escape-html": "^1.0.3", diff --git a/src/components/combobox/Combobox.stories.ts b/src/components/combobox/Combobox.stories.ts index ba22c9085..b97a9aa45 100644 --- a/src/components/combobox/Combobox.stories.ts +++ b/src/components/combobox/Combobox.stories.ts @@ -9,17 +9,31 @@ const meta = { title: 'Combobox', args: { label: 'What is the best food?', - options: 'Pizza, Ramen, Tacos' + options: 'Pizza, Ramen, Tacos', + asyncOptions: false }, argTypes: { label: { control: 'text' }, options: { control: 'text' }, + asyncOptions: { control: 'boolean' }, }, } as const -const render = defineStoryRender(({ label, options }) => { +const render = defineStoryRender(({ label, options, asyncOptions }) => { const parsedOptions = options.split(',').map(option => option.trim()) + if (asyncOptions) { + return html` + + ` + } + return html` ${parsedOptions.map(option => html`${option}`)} @@ -30,3 +44,11 @@ const render = defineStoryRender(({ label, options }) => { export default meta export const Primary = { render } + +export const Async = { + args: { + label: 'Who is the best Disney character?', + asyncOptions: true + }, + render +} diff --git a/src/components/combobox/Combobox.styles.css b/src/components/combobox/Combobox.styles.css index b39ebf388..16480aa1a 100644 --- a/src/components/combobox/Combobox.styles.css +++ b/src/components/combobox/Combobox.styles.css @@ -53,9 +53,12 @@ max-height: inherit; overflow: auto; - [role="option"] { - padding: 12px 8px; + [role="option"], .loading-async-options, .async-options-message { color: var(--solid-ui-color-gray-700); + padding: 12px 8px; + } + + [role="option"] { border-bottom: 1px solid var(--solid-ui-color-gray-100); cursor: pointer; @@ -64,5 +67,19 @@ background: rgba(0, 0, 0, 0.05); } } + + .loading-async-options, .async-options-message { + display: flex; + justify-content: center; + align-items: center; + + icon-svg-spinners-3-dots-fade { + width: 2rem; + } + } + + .async-options-message--error { + color: var(--solid-ui-color-error); + } } } diff --git a/src/components/combobox/Combobox.ts b/src/components/combobox/Combobox.ts index a12811468..fc4d953d1 100644 --- a/src/components/combobox/Combobox.ts +++ b/src/components/combobox/Combobox.ts @@ -1,15 +1,23 @@ import { customElement, WebComponent } from '@/lib/components' -import { html, nothing } from 'lit' +import { Task } from '@lit/task' +import { html, nothing, TemplateResult } from 'lit' import { property, query, state } from 'lit/decorators.js' import InputTrait from '@/lib/components/traits/InputTrait' import type ComboboxOption from '@/components/combobox-option/ComboboxOption' import '~icons/lucide/chevron-down' +import '~icons/svg-spinners/3-dots-fade' import '@awesome.me/webawesome/dist/components/popup/popup.js' import styles from './Combobox.styles.css' -type ComboboxOptionData = { value: string; label: string } +class AsyncOptionsInfo extends Error {} + +type ComboboxOptionData = { + value: string; + label: string | TemplateResult; + selectable: boolean; +} @customElement('solid-ui-combobox') export default class Combobox extends WebComponent { @@ -31,6 +39,18 @@ export default class Combobox extends WebComponent { @property({ type: Boolean, reflect: true }) accessor required = false + @property({ type: String, attribute: 'async-options-url' }) + accessor asyncOptionsUrl = '' + + @property({ type: String, attribute: 'async-options-results-field' }) + accessor asyncOptionsResultsField = '' + + @property({ type: String, attribute: 'async-options-label-field' }) + accessor asyncOptionsLabelField = '' + + @property({ type: String, attribute: 'async-options-value-field' }) + accessor asyncOptionsValueField = '' + @query('input') private accessor inputElement: HTMLInputElement | null = null @@ -45,6 +65,7 @@ export default class Combobox extends WebComponent { private inputTrait: InputTrait private openListenersAttached = false + private asyncOptionsTask?: Task private readonly listboxId: string constructor () { @@ -80,6 +101,14 @@ export default class Combobox extends WebComponent { this.removeOpenListeners() } + protected willUpdate (changedProperties: Map) { + super.willUpdate(changedProperties) + + if (changedProperties.has('asyncOptionsUrl')) { + this.updateAsyncOptionsTask() + } + } + protected render () { const options = this.getFilteredOptions() const activeOption = @@ -134,32 +163,60 @@ export default class Combobox extends WebComponent { ?hidden=${!this.open} @mousedown=${this.onListboxMouseDown} > - ${options.map( - (option, index) => - html`
this.setActiveIndex(index)} - > - ${option.label} -
` - )} + ${options.map((option, index) => { + return option.selectable + ? html`
this.setActiveIndex(index)} + > + ${option.label} +
` + : option.label + })} ` } private getFilteredOptions (): ComboboxOptionData[] { - return this.getOptions().filter((option) => - option.label.toLowerCase().includes(this.filter) + if (this.asyncOptionsTask) { + const options = this.asyncOptionsTask.render({ + complete: (options) => options, + pending: () => [ + { + value: '', + label: html`
`, + selectable: false + } as const, + ], + error: (error) => { + const isError = !(error instanceof AsyncOptionsInfo) + const message = Object(error).message ?? 'Something went wrong' + + return [ + { + value: '', + label: html`
${message}
`, + selectable: false + } as const, + ] + }, + }) + + return options ?? [] + } + + return this.getOptionsFromDOM().filter((option) => + String(option.label).toLowerCase().includes(this.filter) ) } - private getOptions (): ComboboxOptionData[] { + private getOptionsFromDOM (): ComboboxOptionData[] { const options = this.querySelectorAll( 'solid-ui-combobox-option' ) @@ -167,6 +224,7 @@ export default class Combobox extends WebComponent { return Array.from(options).map((option) => ({ value: option.value, label: option.textContent ?? '', + selectable: true, })) } @@ -200,6 +258,43 @@ export default class Combobox extends WebComponent { this.activeIndex = index } + private updateAsyncOptionsTask () { + if (!this.asyncOptionsUrl) { + this.asyncOptionsTask = undefined + + return + } + + this.asyncOptionsTask ??= new Task( + this, + async ([filter]) => { + const response = await fetch( + this.asyncOptionsUrl.replace('%search%', filter) + ) + const data = await response.json() + const results = Array.from( + this.asyncOptionsResultsField + ? data[this.asyncOptionsResultsField] + : data + ) + + if (results.length === 0) { + throw new AsyncOptionsInfo('No results found') + } + + const labelField = this.asyncOptionsLabelField ?? 'label' + const valueField = this.asyncOptionsValueField ?? 'value' + + return results.map((result) => ({ + label: String(Object(result)[labelField]), + value: String(Object(result)[valueField]), + selectable: true, + })) + }, + () => [this.filter] + ) + } + private show (options?: { focusLast?: boolean }) { if (this.open) { return From c9d256343fee9594c0a50fa3f43be6a1cc3c501d Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 7 Jul 2026 10:16:35 +0200 Subject: [PATCH 2/8] Fix combobox display value Prompt: I just implemented async options management, and I realized we have a problem with options where the value and label is different. In the options list, they display properly, but when you select an option, what ends up in the input is the value, not the label; which is confusing from a UX perspective. Can we fix that? AI work summary: - Introduced a new state variable `displayValue` to manage the displayed value in the input. - Updated the `onValueChanged` handler to set `displayValue` based on user input. - Modified `selectOption` method to accept a `ComboboxOptionData` object and update `displayValue` accordingly. - Ensured `displayValue` is initialized with the current value when the component updates. Co-Authored-By: Cursor --- src/components/combobox/Combobox.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/components/combobox/Combobox.ts b/src/components/combobox/Combobox.ts index fc4d953d1..3314979ee 100644 --- a/src/components/combobox/Combobox.ts +++ b/src/components/combobox/Combobox.ts @@ -57,6 +57,9 @@ export default class Combobox extends WebComponent { @state() private accessor filter = '' + @state() + private accessor displayValue = '' + @state() private accessor open = false @@ -77,6 +80,7 @@ export default class Combobox extends WebComponent { getInternals: () => this.getInternals(), onValueChanged: (value) => { this.filter = value.toLowerCase() + this.displayValue = value const options = this.getFilteredOptions() @@ -104,6 +108,10 @@ export default class Combobox extends WebComponent { protected willUpdate (changedProperties: Map) { super.willUpdate(changedProperties) + if (changedProperties.has('value') && this.displayValue === '') { + this.displayValue = this.value + } + if (changedProperties.has('asyncOptionsUrl')) { this.updateAsyncOptionsTask() } @@ -146,7 +154,7 @@ export default class Combobox extends WebComponent { autocomplete="off" spellcheck="false" ?required=${this.required} - .value=${this.value} + .value=${this.displayValue} @keydown=${this.onInputKeyDown} @focus=${this.onInputFocus} @input=${() => this.inputTrait.onInput()} @@ -326,8 +334,11 @@ export default class Combobox extends WebComponent { this.removeOpenListeners() } - private selectOption (value: string) { - this.inputTrait.setValue(value) + private selectOption (option: ComboboxOptionData) { + this.inputTrait.setValue(option.value) + + this.displayValue = + typeof option.label === 'string' ? option.label : option.value this.hide() this.inputElement?.focus({ preventScroll: true }) } @@ -434,7 +445,7 @@ export default class Combobox extends WebComponent { case 'Enter': if (this.open && this.activeIndex >= 0 && options[this.activeIndex]) { event.preventDefault() - this.selectOption(options[this.activeIndex].value) + this.selectOption(options[this.activeIndex]) } else if (!this.open) { event.preventDefault() this.inputTrait.onSubmit() @@ -472,7 +483,7 @@ export default class Combobox extends WebComponent { const option = this.getFilteredOptions()[index] if (option) { - this.selectOption(option.value) + this.selectOption(option) } } } From ea8c2d7f34c3cfddf321ffda470aa98243bd21bb Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 7 Jul 2026 10:24:28 +0200 Subject: [PATCH 3/8] SolidOS/profile-pane#422 Debounce updates in async combobox options This will reduce unnecessary network requests --- src/components/combobox/Combobox.ts | 9 ++++++++- src/lib/timing.ts | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 src/lib/timing.ts diff --git a/src/components/combobox/Combobox.ts b/src/components/combobox/Combobox.ts index 3314979ee..874831c24 100644 --- a/src/components/combobox/Combobox.ts +++ b/src/components/combobox/Combobox.ts @@ -2,6 +2,7 @@ import { customElement, WebComponent } from '@/lib/components' import { Task } from '@lit/task' import { html, nothing, TemplateResult } from 'lit' import { property, query, state } from 'lit/decorators.js' +import { debounce } from '@/lib/timing' import InputTrait from '@/lib/components/traits/InputTrait' import type ComboboxOption from '@/components/combobox-option/ComboboxOption' @@ -57,6 +58,9 @@ export default class Combobox extends WebComponent { @state() private accessor filter = '' + @state() + private accessor debouncedFilter = '' + @state() private accessor displayValue = '' @@ -68,6 +72,7 @@ export default class Combobox extends WebComponent { private inputTrait: InputTrait private openListenersAttached = false + private updateDebouncedFilter = debounce(300, (value) => (this.filter = value)) private asyncOptionsTask?: Task private readonly listboxId: string @@ -81,6 +86,7 @@ export default class Combobox extends WebComponent { onValueChanged: (value) => { this.filter = value.toLowerCase() this.displayValue = value + this.updateDebouncedFilter(this.filter) const options = this.getFilteredOptions() @@ -299,7 +305,7 @@ export default class Combobox extends WebComponent { selectable: true, })) }, - () => [this.filter] + () => [this.debouncedFilter] ) } @@ -329,6 +335,7 @@ export default class Combobox extends WebComponent { } this.filter = '' + this.debouncedFilter = '' this.open = false this.activeIndex = -1 this.removeOpenListeners() diff --git a/src/lib/timing.ts b/src/lib/timing.ts new file mode 100644 index 000000000..d9721539f --- /dev/null +++ b/src/lib/timing.ts @@ -0,0 +1,20 @@ +export interface DebouncedFunction { + (...args: Args): void; + cancel(): void; +} + +export function debounce ( + delay: number, + callback: (...args: Args) => unknown +): DebouncedFunction { + let timeout: unknown + + const debouncedCallback: DebouncedFunction = (...args: Args) => { + debouncedCallback.cancel() + timeout = setTimeout(() => callback(...args), delay) + } + + debouncedCallback.cancel = () => clearTimeout(timeout as number) + + return debouncedCallback +} From 31eb2bbc7e9bcb2723d4d18af1f9d61696b73b02 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 7 Jul 2026 10:53:34 +0200 Subject: [PATCH 4/8] SolidOS/profile-pane#422 Implement async combobox options provider --- src/components/combobox/Combobox.stories.ts | 86 +++++++++++++++------ src/components/combobox/Combobox.ts | 31 ++++++-- src/components/combobox/index.ts | 6 +- 3 files changed, 92 insertions(+), 31 deletions(-) diff --git a/src/components/combobox/Combobox.stories.ts b/src/components/combobox/Combobox.stories.ts index b97a9aa45..3f790219a 100644 --- a/src/components/combobox/Combobox.stories.ts +++ b/src/components/combobox/Combobox.stories.ts @@ -1,5 +1,6 @@ import { html } from 'lit' import { defineStoryRender } from '@/storybook' +import { defineAsyncComboboxOptionsProvider } from './Combobox' import '@/components/combobox-option' @@ -10,45 +11,86 @@ const meta = { args: { label: 'What is the best food?', options: 'Pizza, Ramen, Tacos', - asyncOptions: false + asyncJSOptions: false, + asyncHtmlOptions: false, }, argTypes: { label: { control: 'text' }, options: { control: 'text' }, - asyncOptions: { control: 'boolean' }, + asyncJSOptions: { control: 'boolean' }, + asyncHtmlOptions: { control: 'boolean' }, }, } as const -const render = defineStoryRender(({ label, options, asyncOptions }) => { - const parsedOptions = options.split(',').map(option => option.trim()) +const pokemonProvider = defineAsyncComboboxOptionsProvider(async (query) => { + const response = await fetch('https://beta.pokeapi.co/graphql/v1beta', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + query: ` + query searchPokemon($search: String!) { + pokemon_v2_pokemon(where: {name: {_ilike: $search}}, limit: 20) { + id + name + } + } + `, + variables: { search: `%${query}%` }, + }), + }) + + const { data } = await response.json() + + return data.pokemon_v2_pokemon.map(pokemon => ({ + value: pokemon.id.toString(), + label: pokemon.name.charAt(0).toUpperCase() + pokemon.name.slice(1), + })) +}) + +const render = defineStoryRender( + ({ label, options, asyncJSOptions, asyncHtmlOptions }) => { + if (asyncJSOptions) { + return html`` + } + + if (asyncHtmlOptions) { + return html` + + ` + } + + const parsedOptions = options.split(',').map((option) => option.trim()) - if (asyncOptions) { return html` - + + ${parsedOptions.map((option) => html`${option}`)} + ` } - - return html` - - ${parsedOptions.map(option => html`${option}`)} - - ` -}) +) export default meta export const Primary = { render } -export const Async = { +export const AsyncWithJS = { + args: { + label: 'Who is the best Pokemon?', + asyncJSOptions: true, + }, + render, +} + +export const AsyncWithHtml = { args: { label: 'Who is the best Disney character?', - asyncOptions: true + asyncHtmlOptions: true, }, - render + render, } diff --git a/src/components/combobox/Combobox.ts b/src/components/combobox/Combobox.ts index 874831c24..6707746b9 100644 --- a/src/components/combobox/Combobox.ts +++ b/src/components/combobox/Combobox.ts @@ -14,10 +14,16 @@ import styles from './Combobox.styles.css' class AsyncOptionsInfo extends Error {} -type ComboboxOptionData = { +export type ComboboxOptionData = { value: string; label: string | TemplateResult; - selectable: boolean; + selectable?: boolean; +} + +export type AsyncComboboxOptionsProvider = (query: string) => Promise + +export function defineAsyncComboboxOptionsProvider (provider: T): T { + return provider } @customElement('solid-ui-combobox') @@ -52,6 +58,9 @@ export default class Combobox extends WebComponent { @property({ type: String, attribute: 'async-options-value-field' }) accessor asyncOptionsValueField = '' + @property({ type: Function }) + accessor asyncOptionsProvider: AsyncComboboxOptionsProvider | null = null + @query('input') private accessor inputElement: HTMLInputElement | null = null @@ -118,7 +127,7 @@ export default class Combobox extends WebComponent { this.displayValue = this.value } - if (changedProperties.has('asyncOptionsUrl')) { + if (changedProperties.has('asyncOptionsUrl') || changedProperties.has('asyncOptionsProvider')) { this.updateAsyncOptionsTask() } } @@ -178,7 +187,7 @@ export default class Combobox extends WebComponent { @mousedown=${this.onListboxMouseDown} > ${options.map((option, index) => { - return option.selectable + return option.selectable !== false ? html`
({ value: option.value, label: option.textContent ?? '', - selectable: true, })) } @@ -273,7 +281,7 @@ export default class Combobox extends WebComponent { } private updateAsyncOptionsTask () { - if (!this.asyncOptionsUrl) { + if (!this.asyncOptionsUrl && !this.asyncOptionsProvider) { this.asyncOptionsTask = undefined return @@ -282,6 +290,16 @@ export default class Combobox extends WebComponent { this.asyncOptionsTask ??= new Task( this, async ([filter]) => { + if (this.asyncOptionsProvider) { + const results = await this.asyncOptionsProvider(filter) + + if (results.length === 0) { + throw new AsyncOptionsInfo('No results found') + } + + return results + } + const response = await fetch( this.asyncOptionsUrl.replace('%search%', filter) ) @@ -302,7 +320,6 @@ export default class Combobox extends WebComponent { return results.map((result) => ({ label: String(Object(result)[labelField]), value: String(Object(result)[valueField]), - selectable: true, })) }, () => [this.debouncedFilter] diff --git a/src/components/combobox/index.ts b/src/components/combobox/index.ts index e6f8198e7..6f815e3a1 100644 --- a/src/components/combobox/index.ts +++ b/src/components/combobox/index.ts @@ -1,4 +1,6 @@ -import Combobox from './Combobox' +import Combobox, { defineAsyncComboboxOptionsProvider } from './Combobox' +import type { AsyncComboboxOptionsProvider, ComboboxOptionData } from './Combobox' -export { Combobox } +export { Combobox, defineAsyncComboboxOptionsProvider } +export type { AsyncComboboxOptionsProvider, ComboboxOptionData } export default Combobox From 9e8e1d013105d2177e9730fec54bd43ab8b5eaab Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 7 Jul 2026 12:30:18 +0200 Subject: [PATCH 5/8] SolidOS/profile-pane#422 Implement select-only attribute and change event in Combobox --- src/components/combobox/Combobox.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/components/combobox/Combobox.ts b/src/components/combobox/Combobox.ts index 6707746b9..3f753215a 100644 --- a/src/components/combobox/Combobox.ts +++ b/src/components/combobox/Combobox.ts @@ -46,6 +46,9 @@ export default class Combobox extends WebComponent { @property({ type: Boolean, reflect: true }) accessor required = false + @property({ type: Boolean, reflect: true, attribute: 'select-only' }) + accessor selectOnly = false + @property({ type: String, attribute: 'async-options-url' }) accessor asyncOptionsUrl = '' @@ -95,7 +98,8 @@ export default class Combobox extends WebComponent { onValueChanged: (value) => { this.filter = value.toLowerCase() this.displayValue = value - this.updateDebouncedFilter(this.filter) + + this.updateFilter(value.toLowerCase()) const options = this.getFilteredOptions() @@ -172,7 +176,7 @@ export default class Combobox extends WebComponent { .value=${this.displayValue} @keydown=${this.onInputKeyDown} @focus=${this.onInputFocus} - @input=${() => this.inputTrait.onInput()} + @input=${() => this.selectOnly ? this.updateFilter(this.inputElement?.value ?? '') : this.inputTrait.onInput()} />
@@ -280,6 +284,16 @@ export default class Combobox extends WebComponent { this.activeIndex = index } + private updateFilter (value: string, options: { debounce?: boolean } = {}) { + this.filter = value.toLowerCase() + + if (options.debounce ?? true) { + this.updateDebouncedFilter(this.filter) + } else { + this.debouncedFilter = this.filter + } + } + private updateAsyncOptionsTask () { if (!this.asyncOptionsUrl && !this.asyncOptionsProvider) { this.asyncOptionsTask = undefined @@ -351,11 +365,10 @@ export default class Combobox extends WebComponent { return } - this.filter = '' - this.debouncedFilter = '' this.open = false this.activeIndex = -1 this.removeOpenListeners() + this.updateFilter('', { debounce: false }) } private selectOption (option: ComboboxOptionData) { @@ -365,6 +378,7 @@ export default class Combobox extends WebComponent { typeof option.label === 'string' ? option.label : option.value this.hide() this.inputElement?.focus({ preventScroll: true }) + this.dispatchEvent(new Event('change', { bubbles: true, composed: true })) } private scrollActiveOptionIntoView () { From 0b469c54568238744c544019b7803e1405e94bc2 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 8 Jul 2026 10:34:48 +0200 Subject: [PATCH 6/8] 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 it also adds some new functionality like fallback options, a selectedOption getter, the "change" event, etc. --- src/components/combobox/Combobox.ts | 119 ++++++++++++++---------- src/components/combobox/index.ts | 7 +- src/lib/components/traits/InputTrait.ts | 11 +-- 3 files changed, 78 insertions(+), 59 deletions(-) diff --git a/src/components/combobox/Combobox.ts b/src/components/combobox/Combobox.ts index 3f753215a..6d806f1f8 100644 --- a/src/components/combobox/Combobox.ts +++ b/src/components/combobox/Combobox.ts @@ -15,11 +15,14 @@ import styles from './Combobox.styles.css' class AsyncOptionsInfo extends Error {} export type ComboboxOptionData = { - value: string; - label: string | TemplateResult; + value: unknown; + label: string; + template?: TemplateResult; selectable?: boolean; } +export type ComboboxChangeEvent = CustomEvent<{ option: ComboboxOptionData }> + export type AsyncComboboxOptionsProvider = (query: string) => Promise export function defineAsyncComboboxOptionsProvider (provider: T): T { @@ -37,7 +40,7 @@ export default class Combobox extends WebComponent { @property({ type: String, reflect: true }) accessor name = '' - @property({ type: String }) + @property() accessor value = '' @property({ type: String, reflect: true }) @@ -64,15 +67,15 @@ export default class Combobox extends WebComponent { @property({ type: Function }) accessor asyncOptionsProvider: AsyncComboboxOptionsProvider | null = null + @property({ type: Array }) + accessor optionsFallback: ComboboxOptionData[] | null = null + @query('input') private accessor inputElement: HTMLInputElement | null = null @state() private accessor filter = '' - @state() - private accessor debouncedFilter = '' - @state() private accessor displayValue = '' @@ -86,6 +89,7 @@ export default class Combobox extends WebComponent { private openListenersAttached = false private updateDebouncedFilter = debounce(300, (value) => (this.filter = value)) private asyncOptionsTask?: Task + private _selectedOption: ComboboxOptionData | undefined private readonly listboxId: string constructor () { @@ -95,29 +99,16 @@ export default class Combobox extends WebComponent { new InputTrait(this, { getInputElement: () => this.inputElement, getInternals: () => this.getInternals(), - onValueChanged: (value) => { - this.filter = value.toLowerCase() - this.displayValue = value - - this.updateFilter(value.toLowerCase()) - - const options = this.getFilteredOptions() - - if (options.length === 0) { - this.hide() - return - } - - if (this.open) { - this.activeIndex = this.getInitialActiveIndex(options) - } - }, }) ) this.listboxId = `listbox-${this.inputTrait.inputId}` } + get selectedOption (): ComboboxOptionData | undefined { + return this._selectedOption + } + disconnectedCallback () { super.disconnectedCallback() @@ -127,13 +118,26 @@ export default class Combobox extends WebComponent { protected willUpdate (changedProperties: Map) { super.willUpdate(changedProperties) - if (changedProperties.has('value') && this.displayValue === '') { - this.displayValue = this.value - } - if (changedProperties.has('asyncOptionsUrl') || changedProperties.has('asyncOptionsProvider')) { this.updateAsyncOptionsTask() } + + if (changedProperties.has('value')) { + const options = this.getFilteredOptions() + const option = options.find((option) => option.selectable !== false && option.value === this.value) ?? + this.optionsFallback?.find((option) => option.value === this.value) + const optionLabel = option?.selectable !== false && option?.label + + this.updateDisplayValue(optionLabel || this.value) + + if (this.open) { + this.activeIndex = this.getInitialActiveIndex(options) + } + + if (this._selectedOption && this._selectedOption.value !== this.value) { + this._selectedOption = undefined + } + } } protected render () { @@ -176,7 +180,7 @@ export default class Combobox extends WebComponent { .value=${this.displayValue} @keydown=${this.onInputKeyDown} @focus=${this.onInputFocus} - @input=${() => this.selectOnly ? this.updateFilter(this.inputElement?.value ?? '') : this.inputTrait.onInput()} + @input=${() => this.selectOnly ? this.updateDisplayValue(this.inputElement?.value ?? '') : this.inputTrait.onInput()} /> @@ -201,9 +205,9 @@ export default class Combobox extends WebComponent { data-active=${index === this.activeIndex || nothing} @mousemove=${() => this.setActiveIndex(index)} > - ${option.label} + ${option.template ?? option.label} ` - : option.label + : option.template ?? option.label })} @@ -212,12 +216,14 @@ export default class Combobox extends WebComponent { private getFilteredOptions (): ComboboxOptionData[] { if (this.asyncOptionsTask) { - const options = this.asyncOptionsTask.render({ + return this.asyncOptionsTask.render({ + initial: () => this.optionsFallback ?? [], complete: (options) => options, pending: () => [ { value: '', - label: html`
`, + label: 'Loading...', + template: html`
`, selectable: false } as const, ], @@ -228,14 +234,13 @@ export default class Combobox extends WebComponent { return [ { value: '', - label: html`
${message}
`, + label: message, + template: html`
${message}
`, selectable: false } as const, ] }, }) - - return options ?? [] } return this.getOptionsFromDOM().filter((option) => @@ -284,13 +289,17 @@ export default class Combobox extends WebComponent { this.activeIndex = index } - private updateFilter (value: string, options: { debounce?: boolean } = {}) { - this.filter = value.toLowerCase() + private updateDisplayValue (value: unknown) { + this.displayValue = String(value) - if (options.debounce ?? true) { - this.updateDebouncedFilter(this.filter) - } else { - this.debouncedFilter = this.filter + if (this.open) { + const filter = this.displayValue.toLowerCase() + + if (this.asyncOptionsTask) { + this.updateDebouncedFilter(filter) + } else { + this.filter = filter + } } } @@ -333,10 +342,10 @@ export default class Combobox extends WebComponent { return results.map((result) => ({ label: String(Object(result)[labelField]), - value: String(Object(result)[valueField]), + value: Object(result)[valueField], })) }, - () => [this.debouncedFilter] + () => [this.filter] ) } @@ -365,20 +374,26 @@ export default class Combobox extends WebComponent { return } + this.filter = '' this.open = false this.activeIndex = -1 this.removeOpenListeners() - this.updateFilter('', { debounce: false }) + this.updateDebouncedFilter.cancel() } private selectOption (option: ComboboxOptionData) { - this.inputTrait.setValue(option.value) + const previousValue = this.value + + this._selectedOption = option - this.displayValue = - typeof option.label === 'string' ? option.label : option.value this.hide() + this.inputTrait.setValue(option.value) this.inputElement?.focus({ preventScroll: true }) - this.dispatchEvent(new Event('change', { bubbles: true, composed: true })) + this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true, detail: { option } })) + + if (previousValue === this.value) { + this.updateDisplayValue(option.label) + } } private scrollActiveOptionIntoView () { @@ -442,6 +457,10 @@ export default class Combobox extends WebComponent { } private onAnchorMouseDown (event: MouseEvent) { + if (event.target === this.inputElement) { + return + } + event.preventDefault() this.inputElement?.focus({ preventScroll: true }) } @@ -500,6 +519,10 @@ export default class Combobox extends WebComponent { case 'Tab': this.hide() break + default: + if (!this.open) { + this.show() + } } } diff --git a/src/components/combobox/index.ts b/src/components/combobox/index.ts index 6f815e3a1..af40ed727 100644 --- a/src/components/combobox/index.ts +++ b/src/components/combobox/index.ts @@ -1,6 +1,5 @@ -import Combobox, { defineAsyncComboboxOptionsProvider } from './Combobox' -import type { AsyncComboboxOptionsProvider, ComboboxOptionData } from './Combobox' +import Combobox from './Combobox' -export { Combobox, defineAsyncComboboxOptionsProvider } -export type { AsyncComboboxOptionsProvider, ComboboxOptionData } +export * from './Combobox' +export { Combobox } export default Combobox diff --git a/src/lib/components/traits/InputTrait.ts b/src/lib/components/traits/InputTrait.ts index c4d807af3..20670e46b 100644 --- a/src/lib/components/traits/InputTrait.ts +++ b/src/lib/components/traits/InputTrait.ts @@ -9,13 +9,12 @@ export type InputTraitTarget = WebComponent & { name: string; label: string; required: boolean; - value: string; + value: unknown; } export interface InputTraitConfig { getInputElement(): HTMLInputElement | HTMLSelectElement | null getInternals(): ElementInternals - onValueChanged?: (value: string) => void } export default class InputTrait implements WebComponentTrait { @@ -32,7 +31,7 @@ export default class InputTrait implements WebComponentTrait { } firstUpdated () { - this.config.getInternals().setFormValue(this.target.value) + this.config.getInternals().setFormValue(String(this.target.value)) this.updateValidity() } @@ -46,7 +45,6 @@ export default class InputTrait implements WebComponentTrait { this.target.value = '' this.config.getInternals().setFormValue('') this.updateValidity() - this.config.onValueChanged?.('') } renderLabel () { @@ -63,12 +61,11 @@ export default class InputTrait implements WebComponentTrait { this.config.getInternals().form?.requestSubmit() } - setValue (value: string) { + setValue (value: unknown) { this.target.value = value - this.config.getInternals().setFormValue(this.target.value) + this.config.getInternals().setFormValue(String(this.target.value)) this.target.dispatchEvent(new InputEvent('input', { bubbles: true, composed: true })) - this.config.onValueChanged?.(this.target.value) } private updateValidity () { From 68edc158ae7ac04ca4c53b1c9936ae27381714d6 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 8 Jul 2026 16:51:13 +0200 Subject: [PATCH 7/8] SolidOS/profile-pane#422 Improve Select - Add `options` getter and setter - Add default option - Implement `change` event --- src/components/select/Select.ts | 56 ++++++++++++++++++++++++++------- src/components/select/index.ts | 1 + 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/components/select/Select.ts b/src/components/select/Select.ts index 4aacf4cb7..1d77cb9de 100644 --- a/src/components/select/Select.ts +++ b/src/components/select/Select.ts @@ -1,6 +1,6 @@ import { customElement, WebComponent } from '@/lib/components' -import { html } from 'lit' -import { property, query } from 'lit/decorators.js' +import { html, nothing } from 'lit' +import { property, query, state } from 'lit/decorators.js' import InputTrait from '@/lib/components/traits/InputTrait' import type SelectOption from '@/components/select-option/SelectOption' @@ -8,6 +8,13 @@ import '~icons/lucide/chevron-down' import styles from './Select.styles.css' +export type SelectOptionData = { + value: unknown; + label: string; +} + +export type SelectChangeEvent = CustomEvent<{ option: SelectOptionData }> + @customElement('solid-ui-select') export default class Select extends WebComponent { static styles = styles @@ -25,9 +32,32 @@ export default class Select extends WebComponent { @property({ type: Boolean, reflect: true }) accessor required = false; + @property({ type: Array }) + set options (value: SelectOptionData[] | null) { + this._options = value + } + + get options (): SelectOptionData[] { + if (this._options) { + return this._options + } + + const options = this.querySelectorAll( + 'solid-ui-select-option' + ) + + return Array.from(options).map((option) => ({ + value: option.value, + label: option.textContent, + })) + } + @query('select') accessor inputElement: HTMLSelectElement | null = null; + @state() + private accessor _options: SelectOptionData[] | null = null + private inputTrait: InputTrait constructor () { @@ -40,6 +70,10 @@ export default class Select extends WebComponent { } protected render () { + const defaultOption = this.options.some(option => !option.value) + ? nothing + : html`` + return html` ${this.inputTrait.renderLabel()} @@ -48,9 +82,10 @@ export default class Select extends WebComponent { id="${this.inputTrait.inputId}" name=${this.name} ?required=${this.required} - @change=${() => this.inputTrait.onInput()} + @change=${this.onChange} > - ${this.getOptions().map( + ${defaultOption} + ${this.options.map( (option) => html`