BasecoatUI components for htmy.

Sidebar

Example:

See the sidebar of the page.


Code example:

from htmy import ComponentType, html

from htmui.basecoat.separator import separator
from htmui.basecoat.sidebar import (
    sidebar,
    sidebar_group,
    sidebar_item,
    sidebar_toggle,
    submenu,
)

_sidebar_id = "sidebar"

_hrefs = {
    "Dashboard": "/",
    "Settings": "/settings/",
    "Profile": "/profile/",
}


def example(current_path: str = "/dashboard/") -> ComponentType:
    return html.div(
        sidebar_toggle("Toggle sidebar", sidebar_id=_sidebar_id),
        sidebar(
            sidebar_group(
                *(
                    sidebar_item(name, href=href, current=current_path == href)
                    for name, href in _hrefs.items()
                ),
                label="Pages",
                label_id="sidebar-pages-group-label",
            ),
            separator,
            sidebar_group(
                submenu(
                    sidebar_item("General", href="#account/general"),
                    sidebar_item("Team", href="#account/team"),
                    sidebar_item("Billing", href="#account/billing"),
                    label="Account",
                    id=f"{_sidebar_id}-account",
                ),
                label="Settings",
                label_id="sidebar-settings-group-label",
            ),
            id=_sidebar_id,
        ),
        class_="relative",
    )

Component implementation:

For more details, see the BasecoatUI documentation.

from __future__ import annotations

from typing import Literal

from htmy import (
    ComponentType,
    PropertyValue,
    SafeStr,
    Tag,
    XBool,
    as_component_sequence,
    html,
    join_classes,
)

from .button import ButtonSize, ButtonVariant
from .typing import HorizontalSide

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


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


SidebarItemVariant = Literal["default", "outline"]
"""Visual variant for menu items and submenu summaries."""

SidebarItemSize = Literal["default", "sm", "lg"]
"""Size for menu items and submenu summaries."""


menu_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-6">'
    '<path stroke-linecap="round" stroke-linejoin="round" '
    'd="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/></svg>'
)
"""`bars-3` icon from https://heroicons.com/."""


def sidebar(
    *children: ComponentType,
    id: str,
    aria_label: str | None = None,
    header: ComponentType | None = None,
    footer: ComponentType | None = None,
    side: HorizontalSide = "left",
    initial_open: bool = True,
    initial_mobile_open: bool = False,
    class_: str | None = None,
    nav_class: str | None = None,
    section_class: str | None = "scrollbar-sm",
    **kwargs: PropertyValue,
) -> ComponentType:
    """
    Sidebar component.

    Arguments:
        *children: The content of the sidebar.
        id: The sidebar's ID.
        aria_label: Accessibility label for the sidebar's navigation component.
        header: Optional content at the top of the sidebar.
        footer: Optional content at the bottom of the sidebar.
        side: Side of the viewport the sidebar attaches to.
        initial_open: Whether the sidebar starts open on desktop. When `False`,
            the sidebar starts hidden and inert.
        initial_mobile_open: Whether the sidebar starts open below the
            mobile breakpoint.
        class_: Additional classes for the root element.
        nav_class: Additional classes for the nav element.
        section_class: Additional classes for the scrollable content section.
        **kwargs: Extra attributes for the root element.
    """
    if not initial_open:
        kwargs["data_initial_open"] = "false"
        kwargs["aria_hidden"] = "true"
        kwargs["inert"] = XBool.true

    if initial_mobile_open:
        kwargs["data_initial_mobile_open"] = "true"

    return html.aside(
        html.nav(
            None if header is None else html.header(*as_component_sequence(header)),
            html.section(*children, class_=section_class),
            None if footer is None else html.footer(*as_component_sequence(footer)),
            **({"aria_label": aria_label} if aria_label else {}),
            class_=nav_class,
        ),
        id=id,
        class_=join_classes("sidebar", class_),
        data_side=side,
        **kwargs,
    )


def sidebar_group(
    *children: ComponentType,
    label: ComponentType | None = None,
    label_id: str | None = None,
    group_class: str | None = None,
    list_class: str | None = None,
    heading_tag: str = "h3",
    **kwargs: PropertyValue,
) -> ComponentType:
    """
    Named navigation group inside the sidebar.

    Arguments:
        *children: The content of the sidebar group.
        label: Optional group heading content.
        label_id: Id of the heading element; also used as `aria-labelledby`.
        group_class: Additional classes for the group wrapper.
        list_class: Additional classes for the list element.
        heading_tag: Tag name for the heading element.
        **kwargs: Attributes passed directly to the group wrapper.
    """
    if label_id is not None:
        kwargs["aria_labelledby"] = label_id

    return html.div(
        None if label is None else Tag(heading_tag)(label, id=label_id),
        html.ul(*children, class_=list_class),
        class_=group_class,
        role="group",
        **kwargs,
    )


def sidebar_item(
    label: ComponentType,
    *,
    href: str | None = None,
    icon: ComponentType | None = None,
    current: bool = False,
    variant: SidebarItemVariant | None = None,
    size: SidebarItemSize | None = None,
    active: bool = False,
    disabled: bool = False,
    keep_mobile_open: bool = False,
    **kwargs: PropertyValue,
) -> ComponentType:
    """
    Sidebar item.

    Arguments:
        label: The label of the item.
        href: Optional destination URL. When omitted, an action button is rendered.
        icon: Optional content rendered before the label.
        current: Whether this is the current page.
        variant: Visual variant of the item.
        size: The size of the item.
        active: Whether the item is in an active state.
        disabled: Whether the item is non-interactive.
        keep_mobile_open: Keep the mobile sidebar open when this item is
            clicked, instead of closing it.
       **kwargs: Attributes passed directly to the item.
    """
    if variant is not None:
        kwargs["data_variant"] = variant
    if size is not None:
        kwargs["data_size"] = size
    if active:
        kwargs["data_active"] = "true"
    if keep_mobile_open:
        kwargs["data_keep_mobile_sidebar_open"] = "true"

    if href is None:
        if disabled:
            kwargs["disabled"] = XBool.true

        return html.li(html.button(icon, html.span(label), type="button", **kwargs))

    if current:
        kwargs["aria_current"] = "page"
    if disabled:
        kwargs["aria_disabled"] = "true"
    return html.li(html.a(icon, html.span(label), href=href, **kwargs))


def submenu(
    *children: ComponentType,
    label: ComponentType,
    id: str | None = None,
    icon: ComponentType | None = None,
    open: bool = False,
    active: bool = False,
    variant: SidebarItemVariant | None = None,
    size: SidebarItemSize | None = None,
    **kwargs: PropertyValue,
) -> ComponentType:
    """
    Sidebar submenu.

    Arguments:
        *children: Items wrapped by the submenu.
        label: Text label shown after the optional icon.
        icon: Optional content rendered before the label.
        open: Whether the submenu starts expanded.
        id: The ID of the element.
        variant: Visual variant for the summary control.
        size: Size for the summary control.
        active: Whether the summary control is in an active state.
        **kwargs: Attributes passed directly to the summary element.
    """
    content_id = None if id is None else f"{id}-content"
    if variant is not None:
        kwargs["data_variant"] = variant
    if size is not None:
        kwargs["data_size"] = size
    if active:
        kwargs["data_active"] = "true"
    if content_id is not None:
        kwargs["aria_controls"] = content_id

    details_props: dict[str, PropertyValue] = {}
    if id is not None:
        details_props["id"] = id
    if open:
        details_props["open"] = XBool.true

    return html.li(
        html.details(
            html.summary(
                icon,
                html.span(label),
                **kwargs,
            ),
            html.ul(*children, id=content_id),
            **details_props,
        )
    )


def sidebar_toggle(
    content: ComponentType = menu_icon,
    *,
    sidebar_id: str,
    class_: str | None = None,
    variant: ButtonVariant | None = None,
    size: ButtonSize | None = None,
    **kwargs: PropertyValue,
) -> ComponentType:
    """
    Button that toggles the sidebar with the given ID.

    Arguments:
        content: Button content. Defaults to `menu_icon`.
        sidebar_id: Id of the sidebar to toggle.
        variant: Button variant.
        size: Button size.
        **kwargs: Other attributes passed directly to the button.
    """
    if class_ is not None:
        kwargs["class_"] = class_
    if variant is not None:
        kwargs["data_variant"] = variant
    if size is not None:
        kwargs["data_size"] = size
    return html.button(
        content,
        type="button",
        onclick=f"document.getElementById('{sidebar_id}')?.toggle()",
        **kwargs,
    )