Coverage for src / lexigram / ui / molecules / action_button.py: 90%
50 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1"""ActionButton molecule component - standardized button with icon support."""
3from __future__ import annotations
5from typing import Any, Literal
7from lexigram.ui.atoms.icons import get_icon
8from lexigram.ui.core.base import Component, el
11class ActionButton(Component):
12 """Standardized action button with icon support and consistent styling."""
14 def __init__(
15 self,
16 label: str,
17 variant: Literal["primary", "secondary", "danger", "ghost", "link"] = "primary",
18 icon: str | None = None,
19 icon_position: Literal["left", "right"] = "left",
20 size: Literal["sm", "md", "lg"] = "md",
21 **props,
22 ) -> None:
23 """
24 Initialize action button.
26 Args:
27 label: Button text
28 variant: Button style variant
29 icon: Icon name (from icons.py)
30 icon_position: Position of icon relative to label
31 size: Button size
32 **props: Additional props (HTMX attributes, type, disabled, etc.)
33 """
34 props.pop("color", None)
35 super().__init__(
36 label=label,
37 color=variant,
38 icon=icon,
39 icon_position=icon_position,
40 size=size,
41 **props,
42 )
43 self.label = label
44 self.variant = variant
45 self.icon = icon
46 self.icon_position = icon_position
47 self.size = size
49 def _get_variant_classes(self) -> str:
50 """Get CSS classes for button variant."""
51 variants = {
52 "primary": "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm",
53 "secondary": "bg-secondary text-secondary-foreground hover:bg-secondary/80 border border-border shadow-sm",
54 "danger": "bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm",
55 "ghost": "hover:bg-accent hover:text-accent-foreground",
56 "link": "text-primary underline-offset-4 hover:underline bg-transparent shadow-none p-0",
57 }
58 return variants.get(self.variant, variants["primary"])
60 def _get_size_classes(self) -> str:
61 """Get CSS classes for button size."""
62 sizes = {
63 "sm": "h-8 px-3 text-xs",
64 "md": "h-9 px-4 py-2",
65 "lg": "h-10 px-8",
66 }
67 return sizes.get(self.size, sizes["md"])
69 def render(self) -> Any:
70 """Render action button."""
71 # Build button classes
72 base_classes = "inline-flex items-center whitespace-nowrap font-medium rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 transition-colors duration-200"
73 variant_classes = self._get_variant_classes()
74 size_classes = (
75 self._get_size_classes() if self.variant != "link" else "px-0 py-0"
76 )
78 # Merge custom classes
79 custom_class = self.props.get("class", self.props.get("class_", ""))
80 button_classes = (
81 f"{base_classes} {variant_classes} {size_classes} {custom_class}".strip()
82 )
84 # Build button content
85 icon_size = "h-4 w-4" if self.size == "sm" else "h-5 w-5"
86 icon_element = get_icon(self.icon, size=icon_size) if self.icon else None
88 # Reserve icon space when requested to avoid layout shifts (e.g., table headers)
89 reserve_icon = self.props.get("reserve_icon", False)
91 content = []
92 if icon_element and self.icon_position == "left":
93 content.append(
94 el("span", icon_element, class_="mr-2" if self.label else ""),
95 )
96 elif reserve_icon and self.icon_position == "left":
97 # Placeholder that matches icon size so header text doesn't shift
98 content.append(
99 el(
100 "span",
101 "",
102 class_=("mr-2 " + icon_size + " inline-block icon-placeholder"),
103 ),
104 )
106 if self.label:
107 content.append(self.label)
109 if icon_element and self.icon_position == "right":
110 content.append(el("span", icon_element, class_="ml-2"))
111 elif reserve_icon and self.icon_position == "right":
112 content.append(
113 el(
114 "span",
115 "",
116 class_=("ml-2 " + icon_size + " inline-block icon-placeholder"),
117 ),
118 )
120 # Determine tag and extra attrs
121 tag = "button"
123 # Extract button-specific props
124 button_attrs = {"class_": button_classes}
126 if self.props.get("href"):
127 tag = "a"
128 button_attrs["href"] = self.props.get("href") # type: ignore[assignment]
129 else:
130 button_attrs["type"] = self.props.get("type", "button")
132 # Pass through all other props (HTMX attributes, etc.)
133 for key, value in self.props.items():
134 if key in [
135 "label",
136 "variant",
137 "icon",
138 "icon_position",
139 "size",
140 "href",
141 "type",
142 "class",
143 "class_",
144 "reserve_icon",
145 ]:
146 # Skip internal-only props (including reserve_icon)
147 continue
148 button_attrs[key] = value
150 return el(tag, *content, **button_attrs)