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]@dataclass
class BreadcrumbItem:
"""
A single item in a breadcrumb navigation trail.
This dataclass represents one segment in a breadcrumb navigation component,
containing a title and an optional URL. Items without URLs typically represent
the current page.
Attributes:
title: Display text for the breadcrumb item
url: Optional URL the breadcrumb item should link to. If None, the item
will be displayed as plain text (typically the current page).
Examples:
>>> home = BreadcrumbItem(title="Home", url="/")
>>> section = BreadcrumbItem(title="Products", url="/products/")
>>> current = BreadcrumbItem(title="Product Details")
"""
#: The title for the breadcrumb
title: str
#: The optional URL for the breadcrumb
url: str | None = None
[docs]class BreadcrumbBlock(Block):
"""
A Bootstrap breadcrumb navigation component.
This widget creates a breadcrumb trail showing the hierarchical path to the current
page. It's typically used to show navigation context and allow users to navigate
back up the hierarchy.
You can create a base BreadcrumbBlock with common starting points (like 'Home')
and then extend it in specific views to add additional breadcrumbs for deeper pages.
Attributes:
tag: HTML tag for the container ('nav')
aria_attributes: Accessibility attributes for the breadcrumb component
title_class: CSS classes to apply to each breadcrumb title
items: Base list of breadcrumb items to always include
breadcrumbs: The OrderedList that holds the actual breadcrumb items
Examples:
Creating a base breadcrumb class:
.. code-block:: python
from wildewidgets import BreadcrumbBlock
class BaseBreadcrumbs(BreadcrumbBlock):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_breadcrumb('Home', url='/home')
Using breadcrumbs in a view:
.. code-block:: python
def get_context_data(self, **kwargs):
kwargs = super().get_context_data(**kwargs)
breadcrumbs = BaseBreadcrumbs()
breadcrumbs.add_breadcrumb('Products', url='/products')
breadcrumbs.add_breadcrumb('Product Detail')
kwargs['breadcrumbs'] = breadcrumbs
return kwargs
"""
tag: str = "nav"
aria_attributes: dict[str, str] = {"label": "breadcrumb"} # type: ignore[misc] # noqa: RUF012
#: Apply this string of CSS classes to each breadcrumb title
title_class: str | None = None
#: The base set of breadcrumb items
items: list[BreadcrumbItem] = [] # noqa: RUF012
def __init__(self, *args, title_class: str | None = None, **kwargs):
"""
Initialize a breadcrumb navigation component.
Args:
*args: Positional arguments passed to parent Block class
title_class: CSS classes to apply to each breadcrumb title
**kwargs: Additional keyword arguments passed to parent Block class
"""
self.title_class = title_class or self.title_class
super().__init__(*args, **kwargs)
#: The list of :py:class:`BreadcrumbItem` objects from which we will
#: build our breadcrumb HTML
self.items = deepcopy(self.items)
#: The ``<ol>`` that holds our breadcrumbs
self.breadcrumbs = OrderedList(name="breadcrumb")
self.add_block(self.breadcrumbs)
[docs] def add_breadcrumb(self, title: str, url: str | None = None) -> None:
"""
Add a new breadcrumb item to the breadcrumb trail.
This method appends a new item to the end of the breadcrumb list.
The last item added will be styled as the active/current item.
Args:
title: Display text for the breadcrumb
url: Optional URL for the breadcrumb link. If None, the item will be
displayed as plain text (typically for the current page).
Examples:
... code-block:: python
from wildewidgets import BreadcrumbBlock
breadcrumbs = BreadcrumbBlock()
breadcrumbs.add_breadcrumb('Home', url='/home')
breadcrumbs.add_breadcrumb('Products', url='/products')
breadcrumbs.add_breadcrumb('Product Detail') # Current page has no URL
"""
self.items.append(BreadcrumbItem(title=title, url=url))
[docs] def get_context_data(self, *args, **kwargs) -> dict[str, Any]:
"""
Prepare the context data for template rendering.
This method builds the actual breadcrumb HTML elements from the stored
breadcrumb items, adding the appropriate classes and marking the last
item as active.
Args:
*args: Positional arguments passed to parent method
**kwargs: Keyword arguments passed to parent method
Returns:
dict: Updated context dictionary with breadcrumb-specific data
"""
for item in self.items:
if self.title_class:
title: Block = Block(item.title, tag="span", css_class=self.title_class)
else:
title = Block(item.title, tag="span")
block = Link(title, url=item.url) if item.url else title
self.breadcrumbs.add_block(block, name="breadcrumb-item")
# Make the last li be active
self.breadcrumbs.blocks[-1].add_class("active") # type: ignore[union-attr]
return super().get_context_data(*args, **kwargs)
[docs] def flatten(self) -> str:
"""
Convert all breadcrumb items to a single string.
This method joins all breadcrumb titles with a separator, which is useful
for creating page titles or meta descriptions that include the full
navigation path.
Returns:
str: All breadcrumb titles joined with " - "
Examples:
... code-block:: python
from wildewidgets import BreadcrumbBlock
breadcrumbs = BreadcrumbBlock()
breadcrumbs.add_breadcrumb('Home')
breadcrumbs.add_breadcrumb('Products')
breadcrumbs.add_breadcrumb('Product Detail')
print(breadcrumbs.flatten())
# this will print: 'Home - Products - Product Detail'
"""
return " - ".join([item.title for item in self.items])
[docs]class Navbar(Block):
"""
A Bootstrap navbar component for site navigation.
This class creates a horizontal Bootstrap navbar that can contain branding,
menus, and other navigation elements. It handles responsive behavior and
can be customized with different colors and breakpoints.
Attributes:
tag: HTML tag for the container ('aside')
block: CSS class for styling ('navbar')
VALID_BREAKPOINTS: Valid viewport sizes for responsive behavior
VALID_BACKGROUND_COLORS: Valid background color options
dark: Whether to use dark styling instead of light
background_color: Optional background color
contents_id: CSS ID for the menu container
branding: Block to display as the navbar brand/logo
contents: List of blocks to include in the navbar
hide_below_viewport: Viewport size at which the menu collapses
container_size: Width of the navbar container
inner: Container block for navbar contents
menu_container: Container for menu items that collapse on small screens
Examples:
With a subclass:
... code-block:: python
from wildewidgets import Navbar, Menu, MenuItem, LinkedImage
class MyMenu(Menu):
items = [MenuItem(text='One', url='/one', icon='target')]
class MyNavbar(Navbar):
branding = LinkedImage(
src='/static/branding.png',
alt='My Brand',
url='#'
)
contents = [MyMenu()]
With constructor arguments:
... code-block:: python
from wildewidgets import Navbar, Menu, MenuItem, LinkedImage
branding = LinkedImage(
src='/static/branding.png',
alt='My Brand',
url='#'
)
items = [MenuItem(text='One', url='/one'), ... ]
menu = Menu(*items)
sidebar = Navbar(menu, branding=branding)
Adding menus later:
... code-block:: python
from wildewidgets import Navbar, Menu, MenuItem
items2 = [MenuItem(text='Foo', url='/foo'), ... ]
menu2 = Menu(*items2)
sidebar.add_to_menu_section(menu2)
Args:
*blocks: Blocks to add to the navbar
contents_id: CSS ID for the menu container
branding: Block to display as the navbar brand/logo
hide_below_viewport: Viewport size at which the menu collapses
container_size: Width of the navbar container
dark: Whether to use dark styling
background_color: Background color name
**kwargs: Additional keyword arguments passed to parent Block class
Raises:
ValueError: If hide_below_viewport is not a valid breakpoint
ValueError: If background_color is not a valid color
"""
#: The HTML tag for the navbar container
tag: str = "aside"
#: The block name for the navbar, used for CSS styling
block: str = "navbar"
#: Valid values for ``navbar-expand-``. From
#:`Navbar: How It Works <https://getbootstrap.com/docs/5.3/components/navbar/#how-it-works>`_
VALID_BREAKPOINTS: Final[list[str]] = ["sm", "md", "lg", "xl", "xxl"]
#: Valid background colors. Note that these are Tabler colors, not Bootstrap
#: colors. We use Tabler colors because Tabler also defines an appropriate
#: foreground color for each background color.
VALID_BACKGROUND_COLORS: Final[list[str]] = [
"blue",
"azure",
"indigo",
"purple",
"pink",
"red",
"orange",
"yellow",
"lime",
"green",
"teal",
"cyan",
"white",
]
#: Set to ``True`` to use a dark background instead of light
dark: bool = False
#: If :py:attr:`dark` is ``True``, set the background color. Default: ``#1d273b``
background_color: str | None = None
#: the CSS id of the menu container. Typically you won't need
#: to change this unless you have namespace collisions with other CSS
#: ids on your page.
contents_id: str = "sidebar-menu"
#: A block that will be displayed at the top of the container.
#: A good choice might be a :py:class:`LinkedImage` or :py:class:`Image`
branding: Block | None = None
#: A list of blocks to include in our sidebar
contents: list[Block] = [] # type: ignore[assignment] # noqa: RUF012
#: The viewport size at which our menu container collapses to be hidden,
#: requiring the hamburger menu to show
hide_below_viewport: str = "lg"
#: The width of our actual navbar
container_size: str = "fluid"
def __init__(
self,
*blocks: Block,
contents_id: str | None = None,
branding: Block | None = None,
hide_below_viewport: str | None = None,
container_size: str | None = None,
dark: bool | None = None,
background_color: str | None = None,
**kwargs,
):
# TODO: why are we not using *blocks? We say in the docstring that we
# accept blocks, but we don't actually use them. We should either use
# them or remove the *blocks argument.
self.contents = list(blocks) if blocks else self.contents
# Set our attributes based on constructor arguments
self.contents_id = contents_id or self.contents_id
self.hide_below_viewport = hide_below_viewport or self.hide_below_viewport
self.container_size = container_size or self.container_size
self.dark = dark if dark is not None else self.dark
self.background_color = (
background_color if background_color is not None else self.background_color
)
if self.hide_below_viewport not in self.VALID_BREAKPOINTS:
msg = (
f'"{self.hide_below_viewport}" is not a valid breakpoint size. '
f"Choose from: {', '.join(self.VALID_BREAKPOINTS)}"
)
raise ValueError(msg)
if (
self.background_color
and self.background_color not in self.VALID_BACKGROUND_COLORS
):
msg = (
f'"{self.background_color}" is not a known color. '
f"Choose from: {', '.join(self.VALID_BACKGROUND_COLORS)}"
)
raise ValueError(msg)
super().__init__(**kwargs)
# Set our hamburger menu breakpoint
self.add_class(f"navbar-expand-{self.hide_below_viewport}")
# light vs dark
if self.dark:
self.add_class("navbar-dark")
# background color
if self.background_color:
self.add_class(f"bg-{self.background_color}")
self.add_class(f"bg-{self.background_color}-fg")
# Set our "role" attribute to make us more accessible
self._attributes["role"] = "navigation"
#: Everything inside our sidebar lives in this inner container
margin_class = "ms-0" if self.container_size == "fluid" else "ms-auto"
self.inner: Block = Container(size=self.container_size, css_class=margin_class)
self.add_block(self.inner)
#: This is the branding block at the start of the navbar
self.branding: Block = branding or deepcopy(self.branding)
self.build_brand()
# The menu toggler button for small viewports
self.inner.add_block(NavigationTogglerButton(target=self.contents_id))
#: This is the container for all menus
self.menu_container: Block = CollapseWidget(
css_id=self.contents_id, css_class="navbar-collapse"
)
self.inner.add_block(self.menu_container)
for block in self.contents:
self.add_to_menu_section(block)
[docs] def add_blocks(self) -> None:
"""
Override the parent method to do nothing.
This method is intentionally empty to prevent blocks from being added
directly to the navbar. Instead, blocks should be added to the menu
section using add_to_menu_section().
"""
[docs] def build_brand(self) -> None:
"""
Build the navbar brand element.
This method adds the brand/logo element to the navbar's inner container.
It can be overridden in subclasses to customize branding behavior.
"""
if self.branding:
# Add the .navbar-brand class to our branding block to make it work
# properly within the .navbar
self.branding.add_class("navbar-brand")
[docs] def activate(self, text: str) -> bool:
"""
Activate the menu item that matches the given text or URL.
This method searches through all Menu blocks in the navbar and activates
the first menu item that matches the provided text or URL.
Args:
text: The text or URL to match against menu items
Returns:
bool: True if a matching menu item was found and activated
Example:
... code-block:: python
from wildewidgets import Navbar, Menu, MenuItem
navbar = Navbar()
menu = Menu(MenuItem(text='Home', url='/'))
navbar.add_to_menu_section(menu)
navbar.activate('Home')
"""
for block in self.menu_container.blocks:
if isinstance(block, Menu):
if block.activate(text):
return True
return False
[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)
[docs]class NavItem(Block):
"""
A navigation item for use in menus.
This widget creates a list item with a link or heading, optionally with an icon.
It represents a single navigation element in a menu or navbar.
Attributes:
tag: HTML tag for the container ('li')
block: CSS class for styling ('nav-item')
icon: Optional icon to display (string name or TablerMenuIcon)
text: The text to display
url: Optional URL to link to
Examples:
With keyword arguments:
.. code-block:: python
from wildewidgets import NavItem, TablerMenuIcon
icon = TablerMenuIcon(icon='home')
item = NavItem(text='Home', url='/', icon=icon)
With a :py:class:`MenuItem`:
.. code-block:: python
from wildewidgets import NavItem, MenuItem
menu_item = MenuItem(text='Home', url='/', icon='home')
item = NavItem(item=menu_item)
As a section heading:
.. code-block:: python
from wildewidgets import NavItem
heading = NavItem(text='Settings')
Keyword Args:
text: The text to display
icon: Optional icon to display (string name or TablerMenuIcon)
url: Optional URL to link to
active: Whether this item should be highlighted as active
item: A MenuItem object to create this NavItem from
**kwargs: Additional keyword arguments passed to parent Block class
Raises:
ValueError: If both item and other parameters are provided
ValueError: If no text is provided
"""
tag: str = "li"
block: str = "nav-item"
#: Either the name of a Bootstrap icon, or a :py:class:`TablerMenuIcon`
#: object
icon: str | TablerMenuIcon | None = None # type: ignore[assignment]
#: The text for the item.
text: str | None = None
#: The URL for the item.
url: str | None = None
def __init__(
self,
text: str | None = None,
icon: str | TablerMenuIcon | None = None,
url: str | None = None,
active: bool = False,
item: MenuItem | None = None,
**kwargs,
):
self.active: bool = active
if item and (text or icon or url or active):
msg = 'Specify "item" or ("text", "icon", "url", "active"), but not both'
raise ValueError(msg)
if item:
self.text = item.text
self.icon = item.icon or deepcopy(self.icon) # type: ignore[assignment]
self.url = item.url or self.url
self.active = item.active
else:
self.text = text or self.text
self.icon = icon or self.icon
self.url = url or self.url
if not self.text:
msg = '"text" is required as either a class attribute or keyword arg'
raise ValueError(msg)
super().__init__(**kwargs)
if self.active:
self.add_class("active")
icon_block: Block | None = None
if self.icon:
if isinstance(self.icon, TablerMenuIcon):
icon_block = self.icon
else:
icon_block = TablerMenuIcon(icon=self.icon)
contents: Block | None = None
if self.url:
contents = Link(url=self.url, css_class="nav-link")
if icon_block:
contents.add_block(icon_block)
contents.add_block(self.text)
else:
contents = MenuHeading(text=self.text)
self.add_block(contents)
# 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"
[docs]class NavLinkToggle(Link):
"""
A toggle control for collapsible content.
This widget creates a link that toggles the visibility of a collapsible
element. It includes styling for showing open/closed state and can
optionally include an icon.
Attributes:
block: CSS class for styling
name: Additional CSS class
data_attributes: Data attributes for Bootstrap collapse functionality
aria_attributes: Accessibility attributes
icon: Optional icon to display
text: The text to display
collapse_id: CSS ID of the element to toggle
Example:
.. code-block:: python
from wildewidgets import NavLinkToggle
toggle = NavLinkToggle(
text='Advanced Options',
collapse_id='advanced-options'
)
Keyword Args:
text: The text to display
icon: Optional icon to display
active: Whether this item should be highlighted as active
collapse_id: CSS ID of the element to toggle
**kwargs: Additional keyword arguments passed to parent Link class
Raises:
NavLinkToggle.RequiredAttrOrKwarg: If collapse_id is not provided
NavLinkToggle.RequiredAttrOrKwarg: If text is not provided
"""
block: str = "nav-link"
name: str = "nav-link-toggle"
data_attributes: dict[str, str] = { # type: ignore[misc] # noqa: RUF012
"toggle": "collapse",
}
aria_attributes: dict[str, str] = {"expanded": "false"} # type: ignore[misc] # noqa: RUF012
#: Either the name of a Bootstrap icon, or a :py:class:`TablerMenuIcon`
#: class or subclass
icon: str | TablerMenuIcon | None = None # type: ignore[assignment]
#: The title for the link
text: str | None = None
#: The CSS id of the Collapse that we control
collapse_id: str | None = None
def __init__(
self,
text: str | None = None,
icon: str | TablerMenuIcon | None = None,
active: bool = False,
collapse_id: str | None = None,
**kwargs,
):
"""
Initialize a navigation link toggle.
"""
#: This item is active, but nothing in the related :py:class:`DropdownMenu` is
self.active: bool = active
self.text = text or self.text
self.icon = icon or deepcopy(self.icon)
self.collapse_id = collapse_id or self.collapse_id
if self.collapse_id is None:
msg = "collapse_id"
raise self.RequiredAttrOrKwarg(msg)
if not self.text:
msg = "text"
raise self.RequiredAttrOrKwarg(msg)
super().__init__(role="button", **kwargs)
self._data_attributes["target"] = self.collapse_id
# these classes cause the up/down arrow to be nicely separated from the text
self.add_class("d-flex")
self.add_class("flex-row")
self.add_class("justify-content-between")
if self.icon:
if isinstance(self.icon, TablerMenuIcon):
icon_block = self.icon
else:
icon_block = TablerMenuIcon(icon=self.icon)
self.add_block(icon_block)
self.add_block(self.text)
if self.active:
self.add_class("active")
[docs] def expand(self) -> None:
"""
Set the toggle to expanded state.
This updates the aria-expanded attribute to 'true' to indicate
that the associated collapsible element is open.
"""
self._aria_attributes["expanded"] = "true"
[docs] def collapse(self) -> None:
"""
Set the toggle to collapsed state.
This updates the aria-expanded attribute to 'false' to indicate
that the associated collapsible element is closed.
"""
self._aria_attributes["expanded"] = "false"
[docs]class NavDropdownControl(Link):
"""
A control button for dropdown menus.
This widget creates a link that toggles the visibility of a dropdown menu.
It can include an optional icon and handles the open/closed state.
Attributes:
block: CSS class for styling
name: Additional CSS class
data_attributes: Data attributes for Bootstrap dropdown functionality
aria_attributes: Accessibility attributes
icon: Optional icon to display
text: The text to display
button_id: CSS ID of this button (required for connecting to dropdown)
Examples:
... code-block:: python
from wildewidgets import NavDropdownControl
control = NavDropdownControl(
text='Options',
button_id='options-dropdown'
)
control = NavDropdownControl(
text='Options',
icon='gear',
button_id='options-dropdown'
)
Args:
text: The text to display
icon: Optional icon to display
active: Whether this item should be highlighted as active
button_id: CSS ID for this button (required)
**kwargs: Additional keyword arguments passed to parent Link class
Raises:
NavDropdownControl.RequiredAttrOrKwarg: If text is not provided
NavDropdownControl.RequiredAttrOrKwarg: If button_id is not provided
"""
block: str = "nav-link"
name: str = "dropdown-toggle"
data_attributes: dict[str, str] = { # noqa: RUF012
"toggle": "dropdown",
"auto-close": "true",
} # type: ignore[misc]
aria_attributes: dict[str, str] = {"expanded": "false"} # type: ignore[misc] # noqa: RUF012
#: 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 CSS Id to assign to this button. We need this to tie the
#: button to the actual :py:class:`DropdownItem`
button_id: str | None = None
def __init__(
self,
text: str | None = None,
icon: str | TablerMenuIcon | None = None,
active: bool = False,
button_id: str | None = None,
**kwargs,
):
#: This item is active, but nothing in the related :py:class:`DropdownMenu` is
self.active: bool = active
self.text = text or self.text
self.icon = icon or deepcopy(self.icon)
self.button_id = button_id or self.button_id
if not self.text:
msg = "text"
raise self.RequiredAttrOrKwarg(msg)
if not self.button_id:
msg = "button_id"
raise self.RequiredAttrOrKwarg(msg)
super().__init__(css_id=self.button_id, role="button", **kwargs)
if self.icon:
if isinstance(self.icon, TablerMenuIcon):
icon_block = self.icon
else:
icon_block = TablerMenuIcon(icon=self.icon)
self.add_block(icon_block)
self.add_block(self.text)
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._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._aria_attributes["expanded"] = "false"
[docs]class DropdownItem(Link):
"""
An item within a dropdown menu.
This widget creates a link styled for use within dropdown menus.
It can include an optional icon and handles active state.
Attributes:
block: CSS class for styling
icon: Optional icon to display
text: The text to display
Examples:
With no icon:
.. code-block:: python
from wildewidgets import DropdownItem
item = DropdownItem(
text='Edit Profile',
url='/profile/edit'
)
With an icon:
... code-block:: python
from wildewidgets import DropdownItem
item = DropdownItem(
text='Settings',
url='/settings',
icon='gear'
)
Keyword Args:
text: The text to display
icon: Optional icon to display
active: Whether this item should be highlighted as active
item: A MenuItem object to create this DropdownItem from
**kwargs: Additional keyword arguments passed to parent Link class
Raises:
ValueError: If both item and other parameters are provided
ValueError: If no text is provided
ValueError: If no URL is provided
"""
block: str = "dropdown-item"
#: this is either the name of a bootstrap icon, or a :py:class:`TablerMenuIcon`
#: class or subclass
icon: str | TablerMenuIcon | None = None # type: ignore[assignment]
#: The text for the item.
text: str | None = None
def __init__(
self,
text: str | None = None,
icon: str | None = None,
active: bool = False,
item: MenuItem | None = None,
**kwargs,
):
# Does this item represent the page we're on?
self.active: bool = active
if item and (text or icon or kwargs.get("url")):
msg = 'Specify "item" or ("text", "icon", "url"), but not both'
raise ValueError(msg)
if item:
self.text = item.text
self.icon = item.icon or self.icon # type: ignore[assignment]
if item.url:
kwargs["url"] = item.url
self.active = item.active
else:
self.text = text or self.text
self.icon = icon or self.icon
if not self.text:
msg = '"text" is required as either a class attribute or keyword arg'
raise ValueError(msg)
if not self.url:
msg = '"url" is required as either a class attribute or keyword arg'
raise ValueError(msg)
super().__init__(**kwargs)
if self.active:
self.add_class("active")
icon_block: Block | None = None
if self.icon:
icon_block = TablerMenuIcon(icon=self.icon, css_class="text-white") # type: ignore[arg-type]
if icon_block:
self.add_block(icon_block)
self.add_block(self.text)
[docs]class NavDropdownItem(Block):
"""
An item in a :py:class:`Menu` that opens a dropdown submenu.
Typically, you won't use this directly, but instead it will be created for
you from a :py:class:`MenuItem` specification when :py:attr:`MenuItem.items`
is not empty.
Example:
... code-block:: python
from wildewidgets import Menu, MenuItem, NavDropdownItem
sub_items = [
MenuItem(text='One', url='/one', icon='1-circle'),
MenuItem(text='Two', url='/two', icon='2-circle'),
MenuItem(text='Three', url='/three', icon='3-circle'),
]
item = NavDropdownItem(*sub_items, text='My Submenu', icon='target')
menu = Menu(item)
Args:
*items: the list of :py:class:`MenuItem` objects to insert into
this menu
Keyword Args:
text: the text for the menu item
icon: this is either the name of a Bootstrap icon, or a
:py:class:`TablerMenuIcon` class or subclass
Raises:
ValueError: one or more of the settings failed validation
"""
tag: str = "li"
block: str = "nav-item dropdown"
#: this is 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 for the control text. Use this if you want :py:attr:`text` to be
#: clickable separately from opening the dropdown menu
url: str | None = None
#: The list of items in this dropdown menu
items: Iterable[MenuItem] = []
def __init__(
self,
*items: MenuItem,
text: str | None = None,
icon: str | TablerMenuIcon | None = None,
url: str | None = None,
active: bool = False,
**kwargs,
):
#: The control for opening the dropdown menu is active, but nothing in the
#: related :py:class:`DropdownMenu` is active
self.active = active
#: The control that opens and closes :py:attr:`menu`
self.control: NavDropdownControl | ClickableNavDropdownControl
#: The :py:class:`DropdownMenu` that :py:attr:`control` opens
self.menu: DropdownMenu
self.text = text or self.text
self.url = url or self.url
self.icon = icon or deepcopy(self.icon)
if not self.text:
msg = '"text" is required as either a class attribute of a keyword arg'
raise ValueError(msg)
if items:
self.items: Iterable[MenuItem] = items
else:
self.items = deepcopy(self.items)
super().__init__(**kwargs)
if self.url:
# We need to be able to click on the control to get to a page
menu_id: str = f"nav-menu-{self.text.lower()}"
menu_id = re.sub("[ ._]", "-", menu_id)
self.menu = DropdownMenu(*self.items, css_id=menu_id)
self.control = ClickableNavDropdownControl(
menu_id=menu_id,
text=self.text,
icon=self.icon,
url=self.url,
active=self.active,
)
# TODO: should we set up aria-labelledby on the menu to point to the
# control?
else:
# We don't need to be able to click on the control to get to a page
button_id: str = f"nav-item-{self.text.lower()}"
button_id = re.sub("[ ._]", "-", button_id)
self.control = NavDropdownControl(
button_id=button_id,
text=self.text,
icon=self.icon,
active=self.active,
)
self.menu = DropdownMenu(*self.items, button_id=button_id)
self.add_block(self.control)
self.add_block(self.menu)
[docs] def show(self) -> None:
"""
Show our :py:attr:`menu`.
"""
self.control.expand()
self.menu.show()
[docs] def hide(self) -> None:
"""
Hide our :py:attr:`menu`.
"""
self.control.collapse()
self.menu.hide()
[docs] def get_context_data(self, **kwargs) -> dict[str, Any]:
"""
Overrides :py:meth:`wildewidgets.widgets.base.Block.get_context_data`.
Show our menu if either :py:attr:`active` is ``True`` or any item in our
:py:attr:`menu` is active. We do this here instead of in our constructor
so that any menu item activation that happens after instance construction
time is accounted for.
"""
is_active: bool = self.active or any(item.is_active for item in self.items)
if is_active:
self.show()
else:
self.hide()
return super().get_context_data(**kwargs)
# ==============================
# View mixins
# ==============================
[docs]class NavbarMixin:
"""
A mixin for Django class-based views to manage navigation components.
This mixin provides a standardized way to integrate navigation components
(like navbars and submenus) into Django views. It adds navbar instances to
the template context and handles the activation of menu items based on the
current page.
To use this mixin:
1. Define your Navbar classes
2. Subclass this mixin in your views
3. Set the navbar_class and menu_item attributes
4. In your template, render the "menu" and "submenu" context variables
Attributes:
navbar_class: The Navbar subclass to use as primary navigation
menu_item: Text or URL of the item to mark as active in the primary navbar
secondary_navbar_class: Optional Navbar subclass for secondary navigation
secondary_menu_item: Text or URL of the item to mark as active in the
secondary navbar
Examples:
Creating a view with navigation:
.. code-block:: python
class MainNavbar(Navbar):
# Navbar definition...
class ProjectsView(NavbarMixin, ListView):
navbar_class = MainNavbar
menu_item = 'Projects' # Will highlight "Projects" in the menu
def get_queryset(self):
# View implementation...
In your template:
.. code-block:: html
{% wildewidgets menu %}
<div class="container">
<!-- Your page content -->
</div>
"""
#: The :py:class:`Navbar`` subclass that acts as our primary navigation
navbar_class: type[Navbar] = Navbar
#: The text or URL of an item in one of our :py:attr:`navbar_class` menus to
#: set active
menu_item: str | None = None
#: The :py:class:`Navbar` subclass that holds our secondary menus
secondary_navbar_class: type[Navbar] | None = None
#: The text or URL of an item in one of our
#: :py:attr:`secondary_navbar_class` menus to set active
secondary_menu_item: str | None = None
[docs] def get_navbar_class(self) -> type[Navbar]:
"""
Return the :py:class:`Navbar` subclass for the main menu. Overriding
this will allow you to programmatically determine your at view time.
Returns:
The class of the :py:class:`Navbar` subclass to use for our
main menu.
"""
return self.navbar_class
[docs] def get_navbar(self) -> Navbar | None:
"""
Create and return an instance of the primary navbar.
This method instantiates the navbar_class and returns it. Override this
method if you need custom initialization of your navbar.
Returns:
Navbar: An instance of the primary navbar class
Raises:
ImproperlyConfigured: If navbar_class is None
"""
navbar_class = self.get_navbar_class()
if navbar_class:
return navbar_class()
return None
[docs] def get_secondary_navbar_class(self) -> type[Navbar] | None:
"""
Return our :py:class:`Navbar` subclass for the secondary navbar.
Returns:
The class of the :py:class:`Navbar` subclass to use for our
secondary navbar, if any.
"""
return self.secondary_navbar_class
[docs] def get_secondary_navbar(self) -> Navbar | None:
"""
Instantiate and return our :py:class:`Navbar` subclass for our
secondary navbar.
Returns:
The instantiated :py:class:`Navbar` subclass instance to use for our
secondary menu, if any.
"""
secondary_navbar_class = self.get_secondary_navbar_class()
if secondary_navbar_class:
return secondary_navbar_class()
return None
[docs] def get_context_data(self, **kwargs) -> dict[str, Any]:
"""
Add navigation components to the template context.
This method:
1. Creates the primary navbar and adds it as 'menu'
2. Activates the appropriate menu item
3. If applicable, creates the secondary navbar and adds it as 'submenu'
4. Activates the appropriate secondary menu item
Returns:
Dict[str, Any]: The updated context dictionary
"""
kwargs["menu"] = self.get_navbar()
if kwargs["menu"]:
if menu_item := self.get_menu_item():
kwargs["menu"].activate(menu_item)
secondary_navbar = self.get_secondary_navbar()
if secondary_navbar:
kwargs["submenu"] = secondary_navbar
if submenu_item := self.get_secondary_menu_item():
secondary_navbar.activate(submenu_item)
return super().get_context_data(**kwargs) # type: ignore[misc]