Coverage for src / lexigram / admin / ui / layouts / tab_group.py: 0%

54 statements  

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

1"""Tab group component for organising resource pages. 

2 

3Renders an Alpine.js-managed tab bar with content panels. 

4Tabs use CSS visibility toggling (no HTMX requests). 

5""" 

6 

7from __future__ import annotations 

8 

9from dataclasses import dataclass, field 

10from typing import Any 

11 

12from lexigram.ui import Component, Element, raw 

13 

14 

15@dataclass(frozen=True, kw_only=True) 

16class Tab: 

17 """A single tab within a ``TabGroup``.""" 

18 

19 name: str 

20 label: str 

21 schema_fields: list[Any] = field(default_factory=list) 

22 icon: str | None = None 

23 badge: str | int | None = None 

24 

25 

26class TabGroup(Component): 

27 """Render a set of tabs with Alpine.js-driven switching. 

28 

29 Each tab can hold a list of schema fields (for forms) or 

30 arbitrary content (for detail views). 

31 """ 

32 

33 def __init__( 

34 self, 

35 tabs: list[Tab], 

36 default_tab: str | None = None, 

37 **props: Any, 

38 ) -> None: 

39 super().__init__(tabs=tabs, default_tab=default_tab, **props) 

40 self.tabs = tabs 

41 default = default_tab or (tabs[0].name if tabs else "") 

42 self.default_tab = default 

43 

44 def render(self) -> Element: 

45 if not self.tabs: 

46 return Element("div") 

47 

48 x_data = f"{{ activeTab: '{self.default_tab}' }}" 

49 

50 tab_bar = self._render_tab_bar() 

51 panels = self._render_panels() 

52 

53 return Element( 

54 "div", 

55 tab_bar, 

56 panels, 

57 **{"x-data": x_data}, 

58 class_="w-full", 

59 ) 

60 

61 def _render_tab_bar(self) -> Element: 

62 buttons: list[Element] = [] 

63 for tab in self.tabs: 

64 classes = ( 

65 "inline-flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium " 

66 "border-b-2 transition-all duration-200 focus:outline-none " 

67 "data-[active=true]:border-primary-600 data-[active=true]:text-primary-600 " 

68 "border-transparent text-muted-foreground dark:text-muted-foreground " 

69 "hover:text-foreground dark:hover:text-foreground " 

70 "hover:border-border dark:hover:border-border" 

71 ) 

72 

73 btn_content: list[Element | str] = [] 

74 

75 if tab.icon: 

76 btn_content.append( 

77 Element( 

78 "span", 

79 raw(tab.icon), 

80 class_="w-4 h-4", 

81 ) 

82 ) 

83 

84 btn_content.append(Element("span", tab.label)) 

85 

86 if tab.badge is not None: 

87 btn_content.append( 

88 Element( 

89 "span", 

90 str(tab.badge), 

91 class_=( 

92 "ml-1.5 inline-flex items-center px-2 py-0.5 " 

93 "rounded-full text-xs font-medium " 

94 "bg-primary-100 text-primary-800 " 

95 "dark:bg-primary-900 dark:text-primary-200" 

96 ), 

97 ) 

98 ) 

99 

100 button = Element( 

101 "button", 

102 *btn_content, 

103 type="button", 

104 role="tab", 

105 **{ 

106 "@click": f"activeTab = '{tab.name}'", 

107 ":data-active": f"activeTab === '{tab.name}'", 

108 ":aria-selected": f"activeTab === '{tab.name}'", 

109 }, 

110 class_=classes, 

111 ) 

112 buttons.append(button) 

113 

114 return Element( 

115 "div", 

116 *buttons, 

117 role="tablist", 

118 class_="flex border-b border-border overflow-x-auto", 

119 ) 

120 

121 def _render_panels(self) -> Element: 

122 panels: list[Element] = [] 

123 for tab in self.tabs: 

124 content = self._render_tab_content(tab) 

125 panel = Element( 

126 "div", 

127 content, 

128 role="tabpanel", 

129 **{ 

130 "x-show": f"activeTab === '{tab.name}'", 

131 "x-cloak": "", 

132 }, 

133 class_="pt-4", 

134 ) 

135 panels.append(panel) 

136 return Element("div", *panels, class_="w-full") 

137 

138 def _render_tab_content(self, tab: Tab) -> Element: 

139 """Render the content body for a tab. 

140 

141 If the tab has ``schema_fields``, render them as form 

142 controls. Otherwise show a placeholder. 

143 """ 

144 if not tab.schema_fields: 

145 return Element("div") 

146 

147 rows: list[Element] = [] 

148 for schema_field in tab.schema_fields: 

149 rendered = schema_field.render_form(None) 

150 if isinstance(rendered, Element): 

151 rows.append(rendered) 

152 else: 

153 rows.append(Element("div", str(rendered))) 

154 

155 return Element("div", *rows, class_="space-y-4")