Coverage for src / lexigram / ui / atoms / inputs / base.py: 48%

48 statements  

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

1""" 

2Base input component with shared functionality. 

3All input types should inherit from this class. 

4""" 

5 

6from __future__ import annotations 

7 

8from abc import ABC, abstractmethod 

9from typing import Any 

10 

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

12 

13 

14class AbstractInput(ABC, Component): 

15 """ 

16 Abstract base for all input components. 

17 

18 Provides: 

19 - Common props handling (name, value, label, error, disabled, required) 

20 - Shared CSS class generation 

21 - Common wrapper rendering (label + error display) 

22 

23 Subclasses must implement: 

24 - _render_input(): Returns the actual input element 

25 """ 

26 

27 # Shared CSS classes - defined once, used everywhere 

28 BASE_CLASSES = ( 

29 "block w-full rounded-lg border py-1.5 px-3 h-8 shadow-sm " 

30 "border-input bg-card text-foreground " 

31 "placeholder:text-muted-foreground " 

32 "focus:border-ring focus:ring-2 focus:ring-ring " 

33 "focus:ring-offset-1 focus:ring-offset-[var(--card)] " 

34 "sm:text-sm leading-5 transition-all duration-200 " 

35 "disabled:opacity-50 disabled:cursor-not-allowed" 

36 ) 

37 

38 ERROR_CLASSES = "border-destructive focus:border-destructive focus:ring-destructive" 

39 NORMAL_CLASSES = "" 

40 READONLY_CLASSES = "bg-muted opacity-80" 

41 

42 LABEL_CLASSES = "block text-sm font-medium leading-6 text-foreground" 

43 ERROR_MSG_CLASSES = "mt-1 text-xs text-destructive min-h-[1.25rem]" 

44 WRAPPER_CLASSES = "flex flex-col gap-1.5 w-full" 

45 

46 def __init__( 

47 self, 

48 name: str, 

49 value: Any = None, 

50 label: str | None = None, 

51 error: str | None = None, 

52 disabled: bool = False, 

53 required: bool = False, 

54 readonly: bool = False, 

55 **props, 

56 ): 

57 super().__init__(**props) 

58 self.name = name 

59 self.value = value 

60 self.label = label 

61 self.error = error 

62 self.disabled = disabled 

63 self.required = required 

64 self.readonly = readonly 

65 

66 @property 

67 def input_id(self) -> str: 

68 """Get the input ID (from props or fallback to name).""" 

69 return self.props.get("id") or self.name 

70 

71 def _get_input_classes(self, extra_classes: str = "") -> str: 

72 """ 

73 Generate complete CSS class string for input element. 

74 

75 Handles: 

76 - Base styling 

77 - Error state 

78 - Readonly state 

79 - Custom width classes 

80 - Additional classes from props 

81 """ 

82 custom = self.props.get("class_", "") 

83 

84 # Don't add w-full if custom width is specified 

85 has_custom_width = any(w in custom for w in ["w-", "max-w-", "min-w-"]) 

86 

87 parts = [ 

88 self.BASE_CLASSES, 

89 "" if has_custom_width else "w-full", 

90 self.ERROR_CLASSES if self.error else self.NORMAL_CLASSES, 

91 self.READONLY_CLASSES if self.readonly else "", 

92 extra_classes, 

93 custom, 

94 ] 

95 

96 return " ".join(filter(None, parts)) 

97 

98 def _get_extra_props(self, exclude: list[str] | None = None) -> dict: 

99 """ 

100 Extract non-standard props for passthrough to element. 

101 

102 Filters out standard input props and returns the rest 

103 (useful for hx_* attributes, data-* attributes, etc.) 

104 """ 

105 standard_props = { 

106 "name", 

107 "value", 

108 "label", 

109 "error", 

110 "disabled", 

111 "required", 

112 "readonly", 

113 "class_", 

114 "class", 

115 "id", 

116 *(exclude or []), 

117 } 

118 return {k: v for k, v in self.props.items() if k not in standard_props} 

119 

120 def _render_label(self) -> Any: 

121 """Render label element if label text is provided.""" 

122 if not self.label: 

123 return None 

124 

125 return el( 

126 "label", 

127 self.label, 

128 for_=self.input_id, 

129 class_=self.LABEL_CLASSES, 

130 ) 

131 

132 def _render_error(self) -> Any: 

133 """Render error message if present.""" 

134 if not self.error: 

135 return None 

136 

137 return el( 

138 "p", 

139 self.error, 

140 class_=self.ERROR_MSG_CLASSES, 

141 ) 

142 

143 def _render_with_wrapper(self, input_el: Any) -> Any: 

144 """ 

145 Wrap input with label and error message. 

146 

147 If no label is provided, returns just the input element. 

148 """ 

149 if not self.label: 

150 return input_el 

151 

152 return el( 

153 "div", 

154 self._render_label(), 

155 input_el, 

156 self._render_error(), 

157 class_=self.WRAPPER_CLASSES, 

158 ) 

159 

160 @abstractmethod 

161 def _render_input(self) -> Any: 

162 """ 

163 Render the actual input element. 

164 

165 Subclasses MUST implement this method. 

166 Should return the raw input element without wrapper. 

167 """ 

168 ... 

169 

170 def render(self) -> Any: 

171 """ 

172 Render complete input component. 

173 

174 Calls _render_input() and wraps with label/error if needed. 

175 """ 

176 return self._render_with_wrapper(self._render_input())