BasecoatUI components for htmy.

Select

Example:


Code example:

from htmy import ComponentType, html

from htmui.basecoat.select import group, option, select


def example() -> ComponentType:
    return html.div(
        select(
            group(
                option("Apple", value="apple"),
                option("Banana", value="banana", selected=True),
                option("Blueberry", value="blueberry"),
                option("Grapes", value="grapes"),
                option("Pineapple", value="pineapple"),
                heading="Fruits",
                heading_id="fruit-root-fruits",
            ),
            placeholder="Select a fruit",
            name="fruit",
            value="banana",
            trigger_class="w-[180px]",
        ),
        select(
            group(
                option("Apple", value="apple"),
                option("Banana", value="banana", selected=True),
                option("Blueberry", value="blueberry"),
                option("Grapes", value="grapes", selected=True),
                option("Pineapple", value="pineapple"),
                heading="Fruits",
                heading_id="fruits-multi-fruits",
            ),
            placeholder="Select fruits",
            name="fruits",
            multiple=True,
            value=["banana", "grapes"],
            trigger_class="w-[220px]",
        ),
        class_="flex flex-col gap-4",
    )

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/select/"


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


# `chevron-down` icon, matching the BasecoatUI reference markup.
_trigger_icon = SafeStr(
    '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" '
    'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" '
    'class="lucide lucide-chevron-down text-muted-foreground opacity-50 shrink-0">'
    '<path d="m6 9 6 6 6-6" /></svg>'
)


def option(
    *children: ComponentType,
    value: str | None = None,
    label: str | None = None,
    disabled: bool = False,
    selected: bool = False,
    **kwargs: PropertyValue,
) -> ComponentType:
    """
    `div` with `role="option"` for `select`.

    Arguments:
        value: Maps to `data-value`.
        label: Optional `data-label` overriding the trigger/display text.
        disabled: Whether the option is disabled.
        selected: Whether the option is 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 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:
        label: Group heading content.
        heading_id: Id of the heading span; 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 select(  # noqa: C901
    *items: ComponentType,
    name: str,
    placeholder: str | None = None,
    multiple: bool = False,
    close_on_select: bool = False,
    format: Literal["object"] | None = None,
    value: str | list[Any] | None = None,
    disabled: bool = False,
    invalid: bool = False,
    trigger_class: str | None = None,
    listbox_class: str | None = None,
    popover_class: str | None = None,
    class_: str | None = None,
    **kwargs: PropertyValue,
) -> ComponentType:
    """
    Select component root.

    It contains a hidden input, which is submitted under `name`. Internal element IDs
    are derived from `name` automatically.

    Arguments:
        name: The name of the hidden input.
        placeholder: Text shown in the trigger when nothing is selected.
        multiple: Allow selecting multiple options.
        close_on_select: Whether to close the select after selecting an option.
            It only applies to the multi-select case.
        format: Value format submitted by the hidden input. Use `"object"` to
            send the full option object.
        value: Initially selected value. Pass a string for single select or
            a list for multiple select.
        disabled: Disable the trigger button.
        invalid: Mark the select as invalid for accessibility and validation.
        trigger_class: Additional classes for the trigger button.
        listbox_class: Additional classes for the listbox popup.
        popover_class: Additional classes for the popover wrapper.
        class_: Additional classes for the root element.
        **kwargs: Extra attributes for the root element.
    """
    id = f"{name}-root"
    trigger_id = f"{id}-trigger"
    popover_id = f"{id}-popover"
    listbox_id = f"{id}-listbox"

    root_attrs: dict[str, PropertyValue] = dict(kwargs)
    if placeholder is not None:
        root_attrs["data-placeholder"] = placeholder
    if multiple and close_on_select:
        root_attrs["data-close-on-select"] = "true"
    if format is not None:
        root_attrs["data-format"] = format

    trigger_attrs: dict[str, PropertyValue] = {
        "type": "button",
        "id": trigger_id,
        "aria-haspopup": "listbox",
        "aria-expanded": "false",
        "aria-controls": listbox_id,
    }
    if disabled:
        trigger_attrs["disabled"] = XBool.true
    if invalid:
        trigger_attrs["aria-invalid"] = "true"
    trigger_attrs["class"] = trigger_class

    popover_attrs: dict[str, PropertyValue] = {
        "id": popover_id,
        "data-popover": "",
        "aria-hidden": "true",
    }
    if popover_class:
        popover_attrs["class"] = popover_class

    listbox_attrs: dict[str, PropertyValue] = {
        "role": "listbox",
        "id": listbox_id,
        "aria-orientation": "vertical",
        "aria-labelledby": trigger_id,
    }
    if multiple:
        listbox_attrs["aria-multiselectable"] = "true"
    if listbox_class:
        listbox_attrs["class"] = listbox_class

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

    return html.div(
        html.button(
            html.span(placeholder or "", class_="truncate"),
            _trigger_icon,
            **trigger_attrs,
        ),
        html.div(
            html.div(*items, **listbox_attrs),
            **popover_attrs,
        ),
        html.input_(
            type="hidden",
            name=name,
            value=effective_value,
        ),
        id=id,
        class_=join_classes("select", class_),
        **root_attrs,
    )