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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 17:07 +0800
1"""Tab group component for organising resource pages.
3Renders an Alpine.js-managed tab bar with content panels.
4Tabs use CSS visibility toggling (no HTMX requests).
5"""
7from __future__ import annotations
9from dataclasses import dataclass, field
10from typing import Any
12from lexigram.ui import Component, Element, raw
15@dataclass(frozen=True, kw_only=True)
16class Tab:
17 """A single tab within a ``TabGroup``."""
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
26class TabGroup(Component):
27 """Render a set of tabs with Alpine.js-driven switching.
29 Each tab can hold a list of schema fields (for forms) or
30 arbitrary content (for detail views).
31 """
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
44 def render(self) -> Element:
45 if not self.tabs:
46 return Element("div")
48 x_data = f"{{ activeTab: '{self.default_tab}' }}"
50 tab_bar = self._render_tab_bar()
51 panels = self._render_panels()
53 return Element(
54 "div",
55 tab_bar,
56 panels,
57 **{"x-data": x_data},
58 class_="w-full",
59 )
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 )
73 btn_content: list[Element | str] = []
75 if tab.icon:
76 btn_content.append(
77 Element(
78 "span",
79 raw(tab.icon),
80 class_="w-4 h-4",
81 )
82 )
84 btn_content.append(Element("span", tab.label))
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 )
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)
114 return Element(
115 "div",
116 *buttons,
117 role="tablist",
118 class_="flex border-b border-border overflow-x-auto",
119 )
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")
138 def _render_tab_content(self, tab: Tab) -> Element:
139 """Render the content body for a tab.
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")
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)))
155 return Element("div", *rows, class_="space-y-4")