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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-10 04:11 +0800
1from __future__ import annotations
3from typing import Any
5from lexigram.ui.core.base import Component, el
8class Tabs(Component):
9 """
10 A responsive tabbed interface component with smooth animations and client-side switching.
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 """
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
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
34 attrs: dict[str, Any] = {}
35 if x_data:
36 attrs["x_data"] = x_data
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 }
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 )
138class TabPanel(Component):
139 """
140 Wrapper for tab content.
141 Automatically shows/hides based on the parent Tabs' state.
142 """
144 def __init__(self, tab_id: str, *children, **props) -> None:
145 super().__init__(*children, **props)
146 self.id = tab_id
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 )