Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improves handling of null values in the headless combobox #889

Merged
merged 2 commits into from
Sep 11, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ fun RenderContext.comboboxDemo() {
),
) {
readOnly(readOnlyStore.data)
placeholder("Country")
}

icon("pl-2w-5 h-5", content = HeroIcons.selector).clicks handledBy open
Expand Down Expand Up @@ -127,10 +128,11 @@ fun RenderContext.comboboxDemo() {
}
div("flex flex-wrap gap-2") {
buildList {
add(null)
addAll(COUNTRY_LIST.take(2))
addAll(COUNTRY_LIST.takeLast(2))
}.forEach { country ->
quickSelectButton(country, selectionStore)
}.forEachIndexed { index, country ->
quickSelectButton(country, selectionStore, id = "btn-select-$index")
}
}
}
Expand Down Expand Up @@ -159,9 +161,13 @@ private fun RenderContext.highlightedText(text: String, highlight: String) =
}
}

private fun RenderContext.quickSelectButton(country: Country, store: Store<Country?>) {
button("p-2 bg-primary-500 hover:bg-primary-600 shadow rounded text-sm text-primary-900") {
+country.name
private fun RenderContext.quickSelectButton(country: Country?, store: Store<Country?>, id: String? = null) {
button(
"p-2 bg-primary-500 hover:bg-primary-600 shadow rounded text-sm text-primary-900",
id
) {
type("button")
+(country?.name ?: "Nothing")
}.clicks.map { country } handledBy store.update
}

Expand Down
38 changes: 38 additions & 0 deletions headless-demo/tests/components/combobox.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,44 @@ test.describe("To open and close a combobox", () => {

});

test("With a default value of 'null', the input is empty", async ({ page }) => {
const [input, _] = await createLocators(page);

const selection = page.locator("#countries-selection");
await expect(selection).toContainText("null");

await expect(input).toHaveValue("");
});

test.describe("When updating the value via the data-binding", () => {

test("non-null values are reflected in the input element", async ({ page }) => {
const [input, _] = await createLocators(page);

const selection = page.locator("#countries-selection");
await expect(selection).toContainText("null");

const selectionButton = await page.locator('#btn-select-2');
const expectedValue = await selectionButton.textContent();

await selectionButton.click();

await expect(input).toHaveValue(expectedValue!);
});

test("null values are reflected in the input element", async ({ page }) => {
const [input, _] = await createLocators(page);

const selectionButton = await page.locator('#btn-select-2');
await selectionButton.click();

const resetButton = await page.locator('#btn-select-0');
await resetButton.click();

await expect(input).toHaveValue("");
});
})

test.describe("When the input is read-only", () => {

const selectInputText = async (page: Page) => page.locator("#countries-input").selectText();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ class Combobox<E : HTMLElement, T>(tag: Tag<E>, id: String?) : Tag<E> by tag, Op
current.copy(opened = opened)
}

val select: EmittingHandler<T, T> = handleAndEmit { current, selection ->
val select: EmittingHandler<T?, T?> = handleAndEmit { current, selection ->
current.copy(query = "", opened = false).also {
emit(selection)
}
Expand Down Expand Up @@ -546,12 +546,14 @@ class Combobox<E : HTMLElement, T>(tag: Tag<E>, id: String?) : Tag<E> by tag, Op
} handledBy internalState.select
}

private fun format(value: T?): String = value?.let(itemFormat) ?: ""

fun render() {
value(
merge(
internalState.select.map { itemFormat(it) },
internalState.select.map { format(it) },
value.data.flatMapLatest { value ->
internalState.resetQuery.map { value?.let { itemFormat(it) } ?: "" }
internalState.resetQuery.map { format(value) }
},
// Update the input every time the user types in a new value. This is needed because `mountSimple`
// (used internally by `value()`) does not work with repeating identical values. This is needed,
Expand Down Expand Up @@ -828,7 +830,7 @@ class Combobox<E : HTMLElement, T>(tag: Tag<E>, id: String?) : Tag<E> by tag, Op
hook(items)


value.data.mapNotNull { it } handledBy internalState.select
value.data handledBy internalState.select
value.handler?.invoke(this, internalState.select)

opened handledBy internalState.setOpened
Expand All @@ -854,7 +856,7 @@ class Combobox<E : HTMLElement, T>(tag: Tag<E>, id: String?) : Tag<E> by tag, Op
*
* var itemFormat: (T) -> String
*
* val value: DatabindingProperty<T>
* val value: DatabindingProperty<T?>
*
* var filterBy: FilterFunctionProperty
* // params: (Sequence<T>, String) -> Sequence<T> / T.() -> String
Expand Down Expand Up @@ -923,7 +925,7 @@ fun <E : HTMLElement, T> RenderContext.combobox(
*
* var itemFormat: (T) -> String
*
* val value: DatabindingProperty<T>
* val value: DatabindingProperty<T?>
*
* var filterBy: FilterFunctionProperty
* // params: (Sequence<T>, String) -> Sequence<T> / T.() -> String
Expand Down
15 changes: 10 additions & 5 deletions www/src/pages/headless/combobox.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,17 @@ such as a country.
When the input created via `comboboxInput` is focused, a dropdown with suggestions is shown and updated as you
type. When focused, the input shows the current input. Otherwise, the currently selected item is displayed.

It is mandatory to specify a data stream or a store of type `T` as data binding via the `value` property. The component
It is mandatory to specify a data stream or a store of type `T?` as data binding via the `value` property. The component
supports two-way data binding, i.e. it reflects a selected element from the outside by a `Flow<T>`
but also emits the updated selection to the outside via a `Handler`.
but also emits the updated selection to the outside via a `Handler`.

You can navigate within the selection list using the keyboard. By [[Enter]], [[Space]] or a mouse click an item is
selected. If the combo box input loses focus or the user clicks outside the selection list, the dropdown is hidden.
A combo box may not hold a value (e.g. if initially there is no selection or the implementation lets the user
un-select his choice). Thus, the type parameter of the data-binding is nullable. It is possible to specify a
placeholder text via the vanilla `placeholder` attribute exposed by the [`comboboxInput`](#comboboxinput)'s
input element.

Within the selection list the user can navigate using the keyboard. An item is selected via [[Enter]], [[Space]] or a
mouse click. If the combo box input loses focus or the user clicks outside the selection list, the dropdown is hidden.

As typical use cases may offer thousands of items to choose from, the component reduces and filters those in order to
support the visual recognition of a user down to a feasible size, which can be configured via `maximumDisplayedItems`.
Expand Down Expand Up @@ -325,7 +330,7 @@ combobox<T> {

var itemFormat: (T) -> String

val value: DatabindingProperty<T>
val value: DatabindingProperty<T?>

var filterBy: FilterFunctionProperty
// params: (Sequence<T>, String) -> Sequence<T> / T.() -> String
Expand Down
Loading