diff --git a/package-lock.json b/package-lock.json index a821bce43..5b59a9dfd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "solid-ui", - "version": "3.1.3-9", + "version": "3.1.3-10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "solid-ui", - "version": "3.1.3-9", + "version": "3.1.3-10", "hasInstallScript": true, "license": "MIT", "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..9693d2faa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-ui", - "version": "3.1.3-9", + "version": "3.1.3-10", "description": "UI library for Solid applications", "main": "dist/index.cjs.js", "types": "dist/index.d.ts", @@ -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..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' @@ -9,24 +10,87 @@ const meta = { title: 'Combobox', args: { label: 'What is the best food?', - options: 'Pizza, Ramen, Tacos' + options: 'Pizza, Ramen, Tacos', + asyncJSOptions: false, + asyncHtmlOptions: false, }, argTypes: { label: { control: 'text' }, options: { control: 'text' }, + asyncJSOptions: { control: 'boolean' }, + asyncHtmlOptions: { control: 'boolean' }, }, } as const -const render = defineStoryRender(({ label, options }) => { - 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}%` }, + }), + }) - return html` - - ${parsedOptions.map(option => html`${option}`)} - - ` + 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()) + + return html` + + ${parsedOptions.map((option) => html`${option}`)} + + ` + } +) + export default meta export const Primary = { render } + +export const AsyncWithJS = { + args: { + label: 'Who is the best Pokemon?', + asyncJSOptions: true, + }, + render, +} + +export const AsyncWithHtml = { + args: { + label: 'Who is the best Disney character?', + asyncHtmlOptions: 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..6d806f1f8 100644 --- a/src/components/combobox/Combobox.ts +++ b/src/components/combobox/Combobox.ts @@ -1,15 +1,33 @@ 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 { debounce } from '@/lib/timing' 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 {} + +export type ComboboxOptionData = { + 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 { + return provider +} @customElement('solid-ui-combobox') export default class Combobox extends WebComponent { @@ -22,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 }) @@ -31,12 +49,36 @@ 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 = '' + + @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 = '' + + @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 displayValue = '' + @state() private accessor open = false @@ -45,6 +87,9 @@ export default class Combobox extends WebComponent { private inputTrait: InputTrait private openListenersAttached = false + private updateDebouncedFilter = debounce(300, (value) => (this.filter = value)) + private asyncOptionsTask?: Task + private _selectedOption: ComboboxOptionData | undefined private readonly listboxId: string constructor () { @@ -54,32 +99,47 @@ export default class Combobox extends WebComponent { new InputTrait(this, { getInputElement: () => this.inputElement, getInternals: () => this.getInternals(), - onValueChanged: (value) => { - this.filter = 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() this.removeOpenListeners() } + protected willUpdate (changedProperties: Map) { + super.willUpdate(changedProperties) + + 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 () { const options = this.getFilteredOptions() const activeOption = @@ -117,10 +177,10 @@ 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()} + @input=${() => this.selectOnly ? this.updateDisplayValue(this.inputElement?.value ?? '') : this.inputTrait.onInput()} /> @@ -134,32 +194,61 @@ 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 !== false + ? html`
this.setActiveIndex(index)} + > + ${option.template ?? option.label} +
` + : option.template ?? option.label + })} ` } private getFilteredOptions (): ComboboxOptionData[] { - return this.getOptions().filter((option) => - option.label.toLowerCase().includes(this.filter) + if (this.asyncOptionsTask) { + return this.asyncOptionsTask.render({ + initial: () => this.optionsFallback ?? [], + complete: (options) => options, + pending: () => [ + { + value: '', + label: 'Loading...', + template: html`
`, + selectable: false + } as const, + ], + error: (error) => { + const isError = !(error instanceof AsyncOptionsInfo) + const message = Object(error).message ?? 'Something went wrong' + + return [ + { + value: '', + label: message, + template: html`
${message}
`, + selectable: false + } as const, + ] + }, + }) + } + + 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' ) @@ -200,6 +289,66 @@ export default class Combobox extends WebComponent { this.activeIndex = index } + private updateDisplayValue (value: unknown) { + this.displayValue = String(value) + + if (this.open) { + const filter = this.displayValue.toLowerCase() + + if (this.asyncOptionsTask) { + this.updateDebouncedFilter(filter) + } else { + this.filter = filter + } + } + } + + private updateAsyncOptionsTask () { + if (!this.asyncOptionsUrl && !this.asyncOptionsProvider) { + this.asyncOptionsTask = undefined + + return + } + + 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) + ) + 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: Object(result)[valueField], + })) + }, + () => [this.filter] + ) + } + private show (options?: { focusLast?: boolean }) { if (this.open) { return @@ -229,12 +378,22 @@ export default class Combobox extends WebComponent { this.open = false this.activeIndex = -1 this.removeOpenListeners() + this.updateDebouncedFilter.cancel() } - private selectOption (value: string) { - this.inputTrait.setValue(value) + private selectOption (option: ComboboxOptionData) { + const previousValue = this.value + + this._selectedOption = option + this.hide() + this.inputTrait.setValue(option.value) this.inputElement?.focus({ preventScroll: true }) + this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true, detail: { option } })) + + if (previousValue === this.value) { + this.updateDisplayValue(option.label) + } } private scrollActiveOptionIntoView () { @@ -298,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 }) } @@ -339,7 +502,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() @@ -356,6 +519,10 @@ export default class Combobox extends WebComponent { case 'Tab': this.hide() break + default: + if (!this.open) { + this.show() + } } } @@ -377,7 +544,7 @@ export default class Combobox extends WebComponent { const option = this.getFilteredOptions()[index] if (option) { - this.selectOption(option.value) + this.selectOption(option) } } } diff --git a/src/components/combobox/index.ts b/src/components/combobox/index.ts index e6f8198e7..af40ed727 100644 --- a/src/components/combobox/index.ts +++ b/src/components/combobox/index.ts @@ -1,4 +1,5 @@ import Combobox from './Combobox' +export * from './Combobox' export { Combobox } export default Combobox 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`