Coverage for src / lexigram / ui / atoms / button.py: 100%

39 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-10 04:11 +0800

1from __future__ import annotations 

2 

3from typing import Any, Literal 

4 

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

6 

7ButtonVariant = Literal[ 

8 "default", 

9 "secondary", 

10 "destructive", 

11 "outline", 

12 "ghost", 

13 "link", 

14] 

15 

16ButtonSize = Literal["default", "xs", "sm", "lg", "xl", "icon"] 

17 

18 

19_VARIANT_CLASSES: dict[str, str] = { 

20 "default": ( 

21 "bg-primary text-primary-foreground hover:bg-primary/90 " 

22 "shadow-sm" 

23 ), 

24 "secondary": ( 

25 "bg-secondary text-secondary-foreground hover:bg-secondary/80 " 

26 "shadow-sm" 

27 ), 

28 "destructive": ( 

29 "bg-destructive text-destructive-foreground hover:bg-destructive/90 " 

30 "shadow-sm" 

31 ), 

32 "outline": ( 

33 "border border-input bg-background hover:bg-accent hover:text-accent-foreground " 

34 "shadow-sm" 

35 ), 

36 "ghost": ( 

37 "hover:bg-accent hover:text-accent-foreground" 

38 ), 

39 "link": ( 

40 "text-primary underline-offset-4 hover:underline" 

41 ), 

42} 

43 

44_SIZE_CLASSES: dict[str, str] = { 

45 "default": "h-9 px-4 py-2", 

46 "xs": "h-7 rounded-md px-2 text-xs", 

47 "sm": "h-8 rounded-md px-3 text-xs", 

48 "lg": "h-10 rounded-md px-8", 

49 "xl": "h-12 rounded-md px-10 text-base", 

50 "icon": "h-9 w-9", 

51} 

52 

53_BASE_CLASSES = ( 

54 "inline-flex items-center justify-center whitespace-nowrap " 

55 "rounded-md text-sm font-medium " 

56 "transition-colors " 

57 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 " 

58 "disabled:pointer-events-none disabled:opacity-50 " 

59) 

60 

61 

62class Button(Component): 

63 """Button with ShadCN-compatible variants and sizes. 

64 

65 Args: 

66 label: Button text. 

67 variant: Visual style variant. 

68 size: Size variant. 

69 as_child: If True, renders as first child element. 

70 **props: Additional HTML attributes. 

71 """ 

72 

73 def __init__( 

74 self, 

75 label: str = "", 

76 *, 

77 variant: ButtonVariant = "default", 

78 size: ButtonSize = "default", 

79 as_child: bool = False, 

80 **props: Any, 

81 ) -> None: 

82 super().__init__(as_child=as_child, **props) 

83 self.label = label 

84 self.variant = variant 

85 self.size = size 

86 

87 def render(self) -> Any: 

88 variant_cls = _VARIANT_CLASSES.get(self.variant, _VARIANT_CLASSES["default"]) 

89 size_cls = _SIZE_CLASSES.get(self.size, _SIZE_CLASSES["default"]) 

90 

91 cls = " ".join(filter(None, [_BASE_CLASSES, variant_cls, size_cls])) 

92 

93 custom_cls = self.props.get("class_") or self.props.get("class") 

94 if custom_cls: 

95 cls = f"{cls} {custom_cls}" 

96 

97 extra_attrs = { 

98 k: v 

99 for k, v in self.props.items() 

100 if k not in ("class_", "class", "children") 

101 } 

102 extra_attrs.pop("label", None) 

103 extra_attrs.pop("type", None) 

104 

105 return el("button", self.label, class_=cls, type="button", **extra_attrs) 

106 

107 

108class SubmitButton(Component): 

109 """Submit button with automatic loading state. 

110 

111 Args: 

112 label: Button text. 

113 variant: Same as Button. 

114 size: Same as Button. 

115 disabled: Whether button is disabled. 

116 **props: Additional attributes. 

117 """ 

118 

119 def __init__( 

120 self, 

121 label: str = "Submit", 

122 *, 

123 loading_label: str = "Submitting...", 

124 variant: ButtonVariant = "default", 

125 size: ButtonSize = "default", 

126 disabled: bool = False, 

127 **props: Any, 

128 ) -> None: 

129 super().__init__(**props) 

130 self.label = label 

131 self.loading_label = loading_label 

132 self.variant = variant 

133 self.size = size 

134 self.disabled = disabled 

135 

136 def render(self) -> Any: 

137 from lexigram.ui.atoms.spinner import Spinner 

138 

139 variant_cls = _VARIANT_CLASSES.get(self.variant, _VARIANT_CLASSES["default"]) 

140 size_cls = _SIZE_CLASSES.get(self.size, _SIZE_CLASSES["default"]) 

141 cls = " ".join(filter(None, [_BASE_CLASSES, variant_cls, size_cls])) 

142 

143 return el( 

144 "button", 

145 el( 

146 "span", 

147 self.label, 

148 x_show="!loading", 

149 class_="inline-flex items-center", 

150 ), 

151 el( 

152 "span", 

153 el( 

154 "span", 

155 Spinner(size="sm"), 

156 class_="inline-block mr-2", 

157 ), 

158 self.loading_label, 

159 x_show="loading", 

160 x_cloak=True, 

161 class_="inline-flex items-center", 

162 ), 

163 type="submit", 

164 class_=cls, 

165 disabled=self.disabled, 

166 x_data="{ loading: false }", 

167 x_on_click="loading = true", 

168 x_on_htmx_after_request="loading = false", 

169 )