From e482f75d682cdd7ed07a284aa28cf9da202d9b62 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 7 Jul 2026 10:13:42 +0200 Subject: [PATCH 01/10] 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..098120e59 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"], .non-selectable-option { 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); } } + + .non-selectable-option { + display: flex; + justify-content: center; + align-items: center; + + icon-svg-spinners-3-dots-fade { + width: 2rem; + } + + .message--error { + color: var(--solid-ui-color-error); + } + } } } diff --git a/src/components/combobox/Combobox.ts b/src/components/combobox/Combobox.ts index a12811468..da91234ce 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} +
` + : html`
${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%', encodeURIComponent(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 6c036b967a7c6ad18a7169d5913fbc635b56a088 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 7 Jul 2026 10:16:35 +0200 Subject: [PATCH 02/10] 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 da91234ce..57e002384 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 366d0e0b1cb2f53cb55bce68afd1e9e5f3eb7aa5 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 7 Jul 2026 10:24:28 +0200 Subject: [PATCH 03/10] 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 | 27 +++++++++++++++++++++++++++ 2 files changed, 35 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 57e002384..a6b052d67 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..f073ced17 --- /dev/null +++ b/src/lib/timing.ts @@ -0,0 +1,27 @@ +export interface DebouncedFunction { + (...args: Args): void; + cancel(): void; +} + +export function debounce ( + delay: number, + callback: (...args: Args) => unknown +): DebouncedFunction { + let timeout: ReturnType | null = null + + const debouncedCallback: DebouncedFunction = (...args: Args) => { + debouncedCallback.cancel() + timeout = setTimeout(() => callback(...args), delay) + } + + debouncedCallback.cancel = () => { + if (timeout === null) { + return + } + + clearTimeout(timeout) + timeout = null + } + + return debouncedCallback +} From c3f0cd79dd32694642f82ee2e9922bfcdca2b198 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 7 Jul 2026 10:53:34 +0200 Subject: [PATCH 04/10] 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 a6b052d67..71365e185 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%', encodeURIComponent(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 cdff501d79271f77546c7d4eaefaeb03888b4bf1 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Tue, 7 Jul 2026 12:30:18 +0200 Subject: [PATCH 05/10] 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 71365e185..edb03c39a 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 5b148fdfa02a9c70f851315b024bb2cab8a4df77 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 8 Jul 2026 10:34:48 +0200 Subject: [PATCH 06/10] 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 | 15 ++- 3 files changed, 80 insertions(+), 61 deletions(-) diff --git a/src/components/combobox/Combobox.ts b/src/components/combobox/Combobox.ts index edb03c39a..5881f14b0 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} ` - : html`
${option.label}
` + : html`
${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..7cc798938 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 () { @@ -56,25 +54,24 @@ export default class InputTrait implements WebComponentTrait { } onInput () { - this.setValue(this.config.getInputElement()?.value ?? '') + this.setValue(this.config.getInputElement()?.value) } onSubmit () { 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 () { const internals = this.config.getInternals() - if (this.target.required && this.target.value === '') { + if (this.target.required && (this.target.value ?? '') === '') { internals.setValidity( { valueMissing: true }, 'Please fill out this field.', From cca377d83fe3e11934432f2cf01ea8548f34bb59 Mon Sep 17 00:00:00 2001 From: Noel De Martin Date: Wed, 8 Jul 2026 16:51:13 +0200 Subject: [PATCH 07/10] SolidOS/profile-pane#422 Improve Select - Add `options` getter and setter - Add default option - Implement `change` event --- src/components/select/Select.ts | 57 ++++++++++++++++++++++++++------- src/components/select/index.ts | 1 + src/lib/values.ts | 3 ++ 3 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 src/lib/values.ts diff --git a/src/components/select/Select.ts b/src/components/select/Select.ts index 4aacf4cb7..661ff600a 100644 --- a/src/components/select/Select.ts +++ b/src/components/select/Select.ts @@ -1,6 +1,7 @@ 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 { isEmptyValue } from '@/lib/values' import InputTrait from '@/lib/components/traits/InputTrait' import type SelectOption from '@/components/select-option/SelectOption' @@ -8,6 +9,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 +33,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 +71,10 @@ export default class Select extends WebComponent { } protected render () { + const defaultOption = this.options.some(option => isEmptyValue(option.value)) + ? nothing + : html`` + return html` ${this.inputTrait.renderLabel()} @@ -48,9 +83,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`