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

1"""ActionButton molecule component - standardized button with icon support.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any, Literal 

6 

7from lexigram.ui.atoms.icons import get_icon 

8from lexigram.ui.core.base import Component, el 

9 

10 

11class ActionButton(Component): 

12 """Standardized action button with icon support and consistent styling.""" 

13 

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. 

25 

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 

48 

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"]) 

59 

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"]) 

68 

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 ) 

77 

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 ) 

83 

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 

87 

88 # Reserve icon space when requested to avoid layout shifts (e.g., table headers) 

89 reserve_icon = self.props.get("reserve_icon", False) 

90 

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 ) 

105 

106 if self.label: 

107 content.append(self.label) 

108 

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 ) 

119 

120 # Determine tag and extra attrs 

121 tag = "button" 

122 

123 # Extract button-specific props 

124 button_attrs = {"class_": button_classes} 

125 

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") 

131 

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 

149 

150 return el(tag, *content, **button_attrs)