Coverage for src / lexigram / ui / molecules / tabs.py: 36%

25 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 

4 

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

6 

7 

8class Tabs(Component): 

9 """ 

10 A responsive tabbed interface component with smooth animations and client-side switching. 

11 

12 Args: 

13 tabs: List of (label, id) or (label, url) tuples 

14 active_tab: Initially active tab ID or label 

15 client_side: If True, uses Alpine.js for content switching without page load 

16 """ 

17 

18 def __init__( 

19 self, 

20 tabs: list[tuple[str, str]], 

21 active_tab: str | None = None, 

22 client_side: bool = True, 

23 **props, 

24 ): 

25 super().__init__(**props) 

26 self.tabs = tabs 

27 self.active_id = active_tab or (tabs[0][1] if tabs else "") 

28 self.client_side = client_side 

29 

30 def render(self) -> Any: 

31 # Alpine state for client side or simple selection for URL based 

32 x_data = f"{{ activeTab: '{self.active_id}' }}" if self.client_side else None 

33 

34 attrs: dict[str, Any] = {} 

35 if x_data: 

36 attrs["x_data"] = x_data 

37 

38 tab_values = [v for _, v in self.tabs] 

39 keyboard_nav: dict[str, str] = {} 

40 if self.client_side and tab_values: 

41 keyboard_nav = { 

42 "x-on:keydown.left.prevent": f"const i = {tab_values}.indexOf(activeTab); if (i > 0) activeTab = {tab_values}[i - 1]", 

43 "x-on:keydown.right.prevent": f"const i = {tab_values}.indexOf(activeTab); if (i < {len(tab_values)} - 1) activeTab = {tab_values}[i + 1]", 

44 } 

45 

46 return el( 

47 "div", 

48 # Mobile dropdown 

49 el( 

50 "div", 

51 el("label", "Select a tab", for_="tabs", class_="sr-only"), 

52 el( 

53 "select", 

54 *[ 

55 el( 

56 "option", 

57 label, 

58 value=value, 

59 selected=(value == self.active_id), 

60 ) 

61 for label, value in self.tabs 

62 ], 

63 id="tabs", 

64 name="tabs", 

65 class_="block w-full rounded-md border-border focus:border-ring focus-visible:ring-ring bg-background text-foreground", 

66 **( 

67 {"x_model": "activeTab"} 

68 if self.client_side 

69 else { 

70 "x_on_change": "window.location.href = $event.target.value", 

71 } 

72 ), 

73 ), 

74 class_="sm:hidden mb-4", 

75 ), 

76 # Desktop tabs 

77 el( 

78 "div", 

79 el( 

80 "div", 

81 el( 

82 "nav", 

83 *[ 

84 el( 

85 "a" if not self.client_side else "button", 

86 label, 

87 class_="whitespace-nowrap border-b-2 py-4 px-1 text-sm font-medium transition-all duration-200 " 

88 + ( 

89 f":class=\"activeTab === '{value}' ? 'border-ring text-primary' : 'border-transparent text-muted-foreground hover:border-border hover:text-foreground'\"" 

90 if self.client_side 

91 else ( 

92 "border-ring text-primary" 

93 if value == self.active_id 

94 else "border-transparent text-muted-foreground hover:border-border hover:text-foreground" 

95 ) 

96 ), 

97 aria_current=( 

98 "page" if value == self.active_id else None 

99 ), 

100 **( 

101 { 

102 "href": value, 

103 "hx_get": value 

104 if value.startswith("/") 

105 else None, 

106 "hx_target": "#main-content", 

107 "hx_swap": "innerHTML", 

108 "hx_push_url": "true", 

109 } 

110 if not self.client_side 

111 else { 

112 "type": "button", 

113 "@click": f"activeTab = '{value}'", 

114 "role": "tab", 

115 "aria_selected": "true" if value == self.active_id else "false", 

116 "aria_controls": f"tabpanel-{value}", 

117 "id": f"tab-{value}", 

118 } 

119 ), 

120 ) 

121 for label, value in self.tabs 

122 ], 

123 class_="-mb-px flex space-x-8", 

124 role="tablist", 

125 aria_label="Tabs", 

126 **keyboard_nav, 

127 ), 

128 class_="border-b border-border", 

129 ), 

130 class_="hidden sm:block mb-6", 

131 ), 

132 # Content container (for children like TabPanel) 

133 el("div", *self.children, class_="mt-4"), 

134 **attrs, 

135 ) 

136 

137 

138class TabPanel(Component): 

139 """ 

140 Wrapper for tab content. 

141 Automatically shows/hides based on the parent Tabs' state. 

142 """ 

143 

144 def __init__(self, tab_id: str, *children, **props) -> None: 

145 super().__init__(*children, **props) 

146 self.id = tab_id 

147 

148 def render(self) -> Any: 

149 return el( 

150 "div", 

151 *self.children, 

152 role="tabpanel", 

153 aria_labelledby=f"tab-{self.id}", 

154 id=f"tabpanel-{self.id}", 

155 x_show=f"activeTab === '{self.id}'", 

156 x_cloak="true", 

157 class_="tab-panel", 

158 )