Coverage for src / lexigram / ui / molecules / form_field.py: 15%

55 statements  

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

1"""FieldSchema molecule component - wrapper for form inputs with label and error.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any 

6 

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

8 

9 

10class FormField(Component): 

11 """Form field wrapper with label, input, error message, and help text.""" 

12 

13 """Form field wrapper with label, input, error message, and help text.""" 

14 

15 def __init__( 

16 self, 

17 input_component: Component, 

18 label: str | None = None, 

19 error: str | None = None, 

20 help_text: str | None = None, 

21 hint: str | None = None, 

22 required: bool = False, 

23 hidden: bool = False, 

24 visible_condition: str | None = None, 

25 **props, 

26 ) -> None: 

27 super().__init__( 

28 input_component=input_component, 

29 label=label, 

30 error=error, 

31 help_text=help_text, 

32 hint=hint, 

33 required=required, 

34 hidden=hidden, 

35 visible_condition=visible_condition, 

36 **props, 

37 ) 

38 self.input_component = input_component 

39 self.label = label 

40 self.error = error 

41 self.help_text = help_text 

42 self.hint = hint 

43 self.required = required 

44 self.hidden = hidden 

45 self.visible_condition = visible_condition 

46 

47 def render(self) -> Any: 

48 # Build container attributes 

49 container_attrs: dict[str, Any] = {"class": "mb-6"} 

50 if self.error: 

51 error_id = f"{getattr(self.input_component, 'id', None) or getattr(self.input_component, 'name', None) or 'field'}-error" 

52 container_attrs["aria_describedby"] = error_id 

53 container_attrs["aria_invalid"] = "true" 

54 

55 # Handle hidden state 

56 if self.hidden: 

57 container_attrs["style"] = "display: none" 

58 

59 # Handle conditional visibility (Alpine.js) 

60 if self.visible_condition: 

61 container_attrs["x-show"] = self.visible_condition 

62 container_attrs["x-cloak"] = True 

63 

64 elements = [] 

65 

66 # Header (Label + Hint) 

67 if self.label or self.hint: 

68 header_parts = [] 

69 

70 if self.label: 

71 label_text = self.label 

72 

73 # Dynamic requirement asterisk 

74 required_if = self.props.get("required_if") 

75 if required_if: 

76 header_parts.append( 

77 el( 

78 "label", 

79 label_text, 

80 el( 

81 "span", 

82 "*", 

83 class_="text-destructive ml-1", 

84 **{"x-show": required_if}, 

85 ), 

86 for_=getattr(self.input_component, "id", None) 

87 or getattr(self.input_component, "name", None), 

88 class_="block text-sm font-medium text-foreground", 

89 ), 

90 ) 

91 elif self.required: 

92 header_parts.append( 

93 el( 

94 "label", 

95 label_text 

96 + el("span", "*", class_="text-destructive ml-1"), 

97 for_=getattr(self.input_component, "id", None) 

98 or getattr(self.input_component, "name", None), 

99 class_="block text-sm font-medium text-foreground", 

100 ), 

101 ) 

102 else: 

103 header_parts.append( 

104 el( 

105 "label", 

106 label_text, 

107 for_=getattr(self.input_component, "id", None) 

108 or getattr(self.input_component, "name", None), 

109 class_="block text-sm font-medium text-foreground", 

110 ), 

111 ) 

112 

113 if self.hint: 

114 header_parts.append( 

115 el( 

116 "span", 

117 self.hint, 

118 class_="text-xs text-muted-foreground italic", 

119 title=self.hint, 

120 ), 

121 ) 

122 

123 elements.append( 

124 el( 

125 "div", 

126 *header_parts, 

127 class_="flex items-center justify-between mb-2", 

128 ), 

129 ) 

130 

131 # Input 

132 try: 

133 rendered_input = self.input_component.render() 

134 elements.append(rendered_input) 

135 except (AttributeError, ValueError, TypeError): 

136 # Fail-safe: avoid bubbling render errors to outer HTML rendering 

137 from lexigram.logging import get_logger 

138 

139 logger = get_logger(__name__) 

140 logger.exception( 

141 "Error rendering input component %s", 

142 getattr(self.input_component, "__class__", None), 

143 ) 

144 elements.append( 

145 el( 

146 "div", 

147 "Error rendering field", 

148 class_="mb-2 p-2 rounded bg-destructive/10 text-destructive", 

149 ), 

150 ) 

151 

152 # Error message 

153 if self.error: 

154 error_id = f"{getattr(self.input_component, 'id', None) or getattr(self.input_component, 'name', None) or 'field'}-error" 

155 elements.append( 

156 el( 

157 "p", 

158 self.error, 

159 id=error_id, 

160 class_="mt-2 text-sm text-destructive font-medium", 

161 ), 

162 ) 

163 

164 # Help text 

165 if self.help_text and not self.error: 

166 elements.append( 

167 el( 

168 "p", 

169 self.help_text, 

170 class_="mt-2 text-sm text-muted-foreground", 

171 ), 

172 ) 

173 

174 return el("div", *elements, **container_attrs) 

175 

176 

177FieldSchema = FormField