BasecoatUI components for htmy.

Combobox

Example:

Single selection

Multiple selection


Code example:

from htmy import ComponentType, html

from htmui.basecoat.combobox import combobox, option

options: list[ComponentType] = [
    option("Apple", value="apple"),
    option("Banana", value="banana", selected=True),
    option("Orange", value="orange", force=True),
    option("Strawberry", value="strawberry", keywords="fields forever"),
    option("Not an option", value="not-an-option", disabled=True),
]


def example() -> ComponentType:
    return html.div(
        html.p("Single selection"),
        combobox(
            *options,
            name="fruit",
            placeholder="Select a fruit",
            value="apple",
            class_="w-60",
        ),
        html.p("Multiple selection"),
        combobox(
            *options,
            name="fruits",
            value=["banana"],
            class_="w-60",
            multi=True,
        ),
        class_="flex flex-col gap-2",
    )

Component implementation:

For more details, see the BasecoatUI documentation.

import json
from typing import Any, Literal

from htmy import ComponentType, PropertyValue, SafeStr, XBool, html, join_classes

__version__ = "0.2.0"
__framework__ = "BasecoatUI"
__framework_version__ = "1"
__framework_url__ = "https://basecoatui.com/components/combobox/"

js = SafeStr(
    '<script src="https://cdn.jsdelivr.net/npm/basecoat-css@1/dist/js/combobox.min.js" defer></script>'
)

dropdown_icon = SafeStr(
    '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" '
    'stroke="currentColor" class="size-5"><path stroke-linecap="round" stroke-linejoin="round" '
    'd="M8.25 15 12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9" /></svg>'
)
"""`chevron-up-down` icon from https://heroicons.com/."""


def option(
    *children: ComponentType,
    value: str | None = None,
    label: str | None = None,
    filter: str | None = None,
    force: bool = False,
    disabled: bool = False,
    keywords: str | None = None,
    selected: bool = False,
    **kwargs: PropertyValue,
) -> ComponentType:
    """
    Search option for `combobox`.

    Arguments:
        value: The value submitted by the hidden input.
        label: Optional label that overrides the option content in
            the displayed input when the `option` is selected.
        filter: Optional filter text.
        force: When `true`, the option always stays visible.
        disabled: Whether the option is disabled and non-selectable.
        keywords: Optional keywords for filtering.
        selected: Whether the option is initially selected.
        **kwargs: Other attributes passed directly to the element.
    """
    kwargs["role"] = "option"
    if value is not None:
        kwargs["data-value"] = value
    if label is not None:
        kwargs["data-label"] = label
    if keywords is not None:
        kwargs["data-keywords"] = keywords
    if filter is not None:
        kwargs["data-filter"] = filter
    if force:
        kwargs["data-force"] = "true"
    if disabled:
        kwargs["aria-disabled"] = "true"
    if selected:
        kwargs["aria-selected"] = "true"
    return html.div(*children, **kwargs)


def group(
    *items: ComponentType,
    heading: ComponentType,
    heading_id: str,
    **kwargs: PropertyValue,
) -> ComponentType:
    """
    `div` with `role="group"`, containing options with a linked heading.

    Arguments:
        heading: Group heading content.
        heading_id: Id of the heading element; also used as `aria-labelledby`.
        **kwargs: Other attributes passed through to the root element.
    """
    kwargs["role"] = "group"
    kwargs["aria-labelledby"] = heading_id
    return html.div(
        html.span(heading, role="heading", id=heading_id),
        *items,
        **kwargs,
    )


def combobox(  # noqa: C901
    *items: ComponentType,
    name: str,
    value: str | list[Any] | None = None,
    placeholder: str | None = None,
    disabled: bool = False,
    invalid: bool = False,
    empty_message: str | None = None,
    class_: str | None = None,
    multi: bool = False,
    auto_highlight: bool = False,
    close_on_select: bool = False,
    data_format: Literal["object"] | None = None,
    **kwargs: PropertyValue,
) -> ComponentType:
    """
    Combobox component root.

    The component is input-first: the visible text input filters the list of
    options, while a hidden input stores the value submitted under `name`.

    Internal element IDs are derived from `name` automatically.

    Arguments:
        name: The name of the hidden input.
        value: The value of the combobox.
        placeholder: Placeholder text for the visible input.
        disabled: Disables the combobox.
        invalid: Mark the input as invalid for accessibility and validation.
        empty_message: Optional message shown when the filter matches nothing.
        class_: Additional classes for the root element.
        multi: Whether multi-selection is enabled.
        auto_highlight: Make the first visible option active when the list opens
            or filters, so Enter can select it without arrowing first.
        close_on_select: Whether to close the popover after selecting an option
            in multi-selection mode.
        data_format: Submitted value format. Use `"object"` to serialize selected
            values as `{ value, label }` objects.
        **kwargs: Extra attributes for the root element.
    """
    root_id = f"{name}-root"
    input_id = f"{root_id}-input"
    popover_id = f"{root_id}-popover"
    listbox_id = f"{root_id}-listbox"

    if multi and close_on_select:
        kwargs["data-close-on-select"] = "true"
    if auto_highlight:
        kwargs["data-auto-highlight"] = "true"
    if data_format is not None:
        kwargs["data-format"] = data_format

    input_attrs: dict[str, PropertyValue] = {
        "type": "text",
        "role": "combobox",
        "placeholder": placeholder,
        "autocomplete": "off",
        "autocorrect": "off",
        "spellcheck": "false",
        "aria-autocomplete": "list",
        "aria-expanded": "false",
        "aria-controls": listbox_id,
        "id": input_id,
    }
    if disabled:
        input_attrs["disabled"] = XBool.true
    if invalid:
        input_attrs["aria-invalid"] = "true"

    listbox_attrs: dict[str, PropertyValue] = {
        "role": "listbox",
        "id": listbox_id,
        "aria-labelledby": input_id,
        "aria-orientation": "vertical",
    }
    if multi:
        listbox_attrs["aria-multiselectable"] = "true"
    if empty_message is not None:
        listbox_attrs["data-empty"] = empty_message

    effective_value: str
    if value is None:
        effective_value = "" if not multi else "[]"
    elif isinstance(value, (list, tuple)):
        effective_value = json.dumps(list(value))
    else:
        effective_value = str(value)

    return html.div(
        html.input_(**input_attrs),
        dropdown_icon,
        html.div(
            html.div(*items, **listbox_attrs),
            id=popover_id,
            data_popover=XBool.true,
            aria_hidden="true",
        ),
        html.input_(type="hidden", name=name, value=effective_value),
        id=root_id,
        class_=join_classes("combobox", class_),
        **kwargs,
    )