Source code for wildewidgets.widgets.navigation

from __future__ import annotations

import re
from copy import deepcopy
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Final

from .base import Block, Container, Link, OrderedList
from .icons import TablerMenuIcon
from .structure import CollapseWidget

if TYPE_CHECKING:
    from collections.abc import Iterable

#: A regular expression that matches a valid URL path.
#: A valid URL path starts with a slash and does not contain whitespace.
path_validator_re = re.compile(r"^/\S+$", re.IGNORECASE)


#: A regular expression that matches a valid URL.
#: A valid URL starts with http:// or https://, followed by a domain or IP address,
#: and may include a port number and path.
#:
#: * A valid URL may also be a localhost URL.
#: * The domain must be a valid top-level domain or a valid IP address.
#: * The port number, if present, must be a valid integer.
#: * The path, if present, must not contain whitespace.
#: * The URL may end with a slash or a query string.
#:
#: Note: This regex is not perfect and may not match all valid URLs, but it should
#:       match most common cases.  It is designed to be simple and easy to read.
#:       If you need a more complex URL validation, consider using a library like
#:       `validators <https://pypi.org/project/validators/>`_ or
#:       :py:class:`django.core.validators.URLValidator`.
url_validator_re = re.compile(
    r"^(?:http)s?://"  # http:// or https://
    r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|"  # domain...  # noqa: E501
    r"localhost|"  # localhost...
    r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"  # ...or ip
    r"(?::\d+)?"  # optional port
    r"(?:/?|[/?]\S+)$",
    re.IGNORECASE,
)


# ==============================
# Functions
# ==============================


[docs]def is_url(text: str) -> bool: """ Check if a string appears to be a URL or path. This function uses regular expressions to determine if the provided text matches patterns for either a relative path (starting with slash) or an absolute URL (with protocol). Args: text: The string to check Returns: bool: True if the text looks like a URL or path, False otherwise Examples: >>> is_url("/some/path") True >>> is_url("https://example.com") True >>> is_url("not a url") False """ return bool(path_validator_re.search(text) or url_validator_re.search(text))
# ============================== # Dataclasses # ==============================
[docs]class TablerVerticalNavbar(Navbar): """ A vertical navbar styled with Tabler design elements. This navbar variant is designed to be displayed as a sidebar with a vertical orientation. It has dark styling by default and provides consistent width options. Features: * Vertical orientation instead of horizontal * Dark mode by default * Fixed width (15rem or 18rem if wide=True) * Fixed position for scrolling pages * Includes open/close animations Attributes: block: CSS classes for styling dark: Always True for dark styling wide: If True, use wider 18rem layout instead of 15rem Examples: ... code-block:: python from wildewidgets import TablerVerticalNavbar, Menu, MenuItem, LinkedImage branding = LinkedImage( src='/static/branding.png', alt='My Brand', url='https://example.com', width='100%' ) items = [MenuItem(text='Home', url='/home')] menu = Menu(*items) sidebar = TablerVerticalNavbar(menu, branding=branding) Args: *args: Positional arguments passed to parent Navbar class Keyword Args: wide: If True, use wider 18rem layout instead of 15rem **kwargs: Additional keyword arguments passed to parent Navbar class """ block: str = "navbar navbar-vertical" dark: bool = True #: Make the navbar 18rem wide instead of 15rem wide: bool = False def __init__(self, *args, wide: bool | None = None, **kwargs): self.wide = wide if wide is not None else self.wide super().__init__(*args, **kwargs) if self.wide: self._css_class += " navbar-wide"
[docs] def build_brand(self) -> None: """ Build the navbar brand element with Tabler-specific styling. Overrides the parent method to wrap the branding in an h1 element with appropriate classes for Tabler design. """ if self.branding: brand_container = Block( self.branding, tag="h1", css_class="navbar-brand navbar-brand-autodark flex-grow-1 " f"flex-{self.hide_below_viewport}-grow-0", ) self.inner.add_block(brand_container)
# Submenus:
[docs]class ClickableNavDropdownControl(Block): """ A control for dropdown menus with a separate clickable link. This specialized control provides both a clickable link and a dropdown toggle in one component. It allows the user to either navigate to a URL by clicking the main text, or open a dropdown menu by clicking a separate arrow. Attributes: block: CSS class for styling icon: Optional icon to display text: The text to display as the link url: The URL to navigate to when clicking the text link: The Link object for the clickable text control: The Link object that toggles the dropdown Examples: Basic usage: ... code-block:: python from wildewidgets import ClickableNavDropdownControl control = ClickableNavDropdownControl( 'dropdown-menu-id', text='Products', url='/products' ) With an icon: ... code-block:: python from wildewidgets import ClickableNavDropdownControl control = ClickableNavDropdownControl( 'dropdown-menu-id', text='Products', url='/products', icon='box' ) ) Args: menu_id: CSS ID of the dropdown menu to control Keyword Args: text: The text to display as the link icon: Optional icon to display url: The URL to navigate to when clicking the text active: Whether this item should be highlighted as active **kwargs: Additional keyword arguments passed to parent Block class Raises: ValueError: If no URL is provided ValueError: If no text is provided """ block: str = "nav-item--clickable" #: Either the name of a Bootstrap icon, or a :py:class:`TablerMenuIcon` #: class or subclass icon: str | TablerMenuIcon | None = None # type: ignore[assignment] #: The actual name of the dropdown text: str | None = None #: The URL to associated with the control url: str | None = None def __init__( self, menu_id: str, text: str | None = None, icon: str | TablerMenuIcon | None = None, url: str | None = None, active: bool = False, **kwargs, ): #: If this is ``True``, this control itself is active, but nothing #: in the related :py:class:`DropdownMenu` is self.active: bool = active self.text = text or self.text self.icon: str | TablerMenuIcon | None = icon or deepcopy(self.icon) self.url = url or self.url if not self.url: msg = '"url" is required as either a class attribute of a keyword arg' raise ValueError(msg) if not self.text: msg = '"text" is required as either a class attribute of a keyword arg' raise ValueError(msg) super().__init__(**kwargs) # These classes make the link + control look right for klass in [ "d-flex", "flex-row", "justify-content-between", "align-items-center", ]: self.add_class(klass) self.link = Link(url=self.url, name="nav-link") # make the clickable link if self.icon: if not isinstance(self.icon, TablerMenuIcon): self.link.add_block(TablerMenuIcon(icon=self.icon)) else: self.link.add_block(self.icon) self.link.add_block(self.text) # make the actual dropdown control self.control = Link( css_class="nav-link dropdown-toggle", role="button", data_attributes={ "toggle": "dropdown-ww", # This data-bs-toggle is targeted by our own javascript # noqa: E501 "target": f"#{menu_id}", }, aria_attributes={"expanded": "false"}, ) self.add_block(self.link) self.add_block(self.control) if self.active: self.add_class("active")
[docs] def expand(self) -> None: """ Set the dropdown to expanded state. This updates the aria-expanded attribute to 'true' to indicate that the associated dropdown menu is open. """ self.control._aria_attributes["expanded"] = "true"
[docs] def collapse(self) -> None: """ Set the dropdown to collapsed state. This updates the aria-expanded attribute to 'false' to indicate that the associated dropdown menu is closed. """ self.control._aria_attributes["expanded"] = "false"
# ============================== # View mixins # ==============================