Coverage for src / lexigram / admin / ui / molecules / search_bar.py: 19%

32 statements  

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

1"""SearchBar molecule component - combines search input with icon and clear button.""" 

2 

3from __future__ import annotations 

4 

5from typing import Any 

6 

7from lexigram.ui import Component, TextInput, Zones, el, get_icon 

8 

9 

10class SearchBar(Component): 

11 """Reusable search bar with icon and optional clear button.""" 

12 

13 def __init__( 

14 self, 

15 name: str = "search", 

16 value: str = "", 

17 placeholder: str = "Search...", 

18 show_icon: bool = True, 

19 show_clear: bool = False, 

20 **props, 

21 ) -> None: 

22 """ 

23 Initialize search bar. 

24 

25 Args: 

26 name: Input name attribute 

27 value: Current search value 

28 placeholder: Placeholder text 

29 show_icon: Whether to show search icon 

30 show_clear: Whether to show clear button 

31 **props: Additional props (HTMX attributes, etc.) 

32 """ 

33 super().__init__( 

34 name=name, 

35 value=value, 

36 placeholder=placeholder, 

37 show_icon=show_icon, 

38 show_clear=show_clear, 

39 **props, 

40 ) 

41 self.name = name 

42 self.value = value 

43 self.placeholder = placeholder 

44 self.show_icon = show_icon 

45 self.show_clear = show_clear 

46 

47 def render(self) -> Any: 

48 """Render search bar.""" 

49 # Filter props to avoid duplicates with explicit args 

50 # TextInput args: name, value, placeholder, type, error, disabled, required 

51 excluded_props = [ 

52 "name", 

53 "value", 

54 "placeholder", 

55 "type", 

56 "error", 

57 "disabled", 

58 "required", 

59 ] 

60 text_input_props = { 

61 k: v for k, v in self.props.items() if k not in excluded_props 

62 } 

63 

64 # AlpineJS State wrapping 

65 from lexigram.serialization import dumps_str 

66 

67 wrapper_props = { 

68 "x_data": f"{{ query: {dumps_str(self.value)} }}", 

69 "class_": "relative group", 

70 } 

71 

72 # Inject x-model into input props 

73 text_input_props["x_model"] = "query" 

74 # Ensure we trigger updates on input 

75 text_input_props["@keydown.escape"] = ( 

76 "query = ''; $nextTick(() => $el.dispatchEvent(new Event('input', {bubbles: true})))" 

77 ) 

78 

79 search_input = TextInput( 

80 name=self.name, 

81 value=self.value, 

82 placeholder=self.placeholder, 

83 autocomplete="off", 

84 id=f"{Zones.SEARCH.id}-input", 

85 hx_preserve="true", 

86 **text_input_props, 

87 ) 

88 

89 # Build Inner Content 

90 inner_content = [] 

91 

92 # Icon 

93 if self.show_icon: 

94 inner_content.append( 

95 el( 

96 "div", 

97 get_icon("search", size="h-5 w-5"), 

98 class_="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-muted-foreground", 

99 ), 

100 ) 

101 

102 # Input (wrapped for padding if icon exists) 

103 input_html = search_input.render() 

104 if self.show_icon: 

105 input_html = el( 

106 "div", 

107 input_html, 

108 class_="[&>input]:pl-10 [&>input]:pr-10" 

109 if self.show_clear 

110 else "[&>input]:pl-10", 

111 ) 

112 elif self.show_clear: 

113 input_html = el("div", input_html, class_="[&>input]:pr-10") 

114 

115 inner_content.append(input_html) 

116 

117 # Clear Button 

118 if self.show_clear: 

119 clear_btn = el( 

120 "button", 

121 get_icon("x", size="h-4 w-4", aria_hidden="true"), 

122 type="button", 

123 aria_label="Clear search", 

124 class_="absolute inset-y-0 right-0 pr-3 flex items-center text-muted-foreground hover:text-muted-foreground cursor-pointer", 

125 x_show="query.length > 0", 

126 # On click: clear query, and dispatch 'input' event on the INPUT element (sibling) 

127 # We need to find the input. Since we are inside a relative wrapper, 

128 # generic approach: $el.closest('div.relative').querySelector('input').dispatchEvent(...) 

129 # Simpler: rely on x-model updating the value, then manually trigger the HTMX on the input. 

130 # However, HTMX triggers on the input element. 

131 # Use x_on:click 

132 **{ 

133 "@click": "query = ''; $nextTick(() => { let input = $el.closest('.relative').querySelector('input'); input.dispatchEvent(new Event('input', {bubbles: true})); input.dispatchEvent(new Event('change', {bubbles: true})); })", 

134 }, 

135 ) 

136 inner_content.append(clear_btn) 

137 

138 return el("div", *inner_content, **wrapper_props)