Coverage for src / lexigram / admin / ui / molecules / view_switcher.py: 16%
44 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
1from __future__ import annotations
3from typing import TYPE_CHECKING, Any
5from lexigram.admin.ui.htmx_attrs import HTMXAttrs
6from lexigram.ui import Component, Zones, el
8if TYPE_CHECKING:
9 from lexigram.ui.state import TableState
12class ViewSwitcher(Component):
13 """Simple view switcher dropdown for DataTable.
15 Renders a compact dropdown listing available views and emits HTMX
16 requests to the resource with `data_view` query param.
17 """
19 def __init__(
20 self,
21 current: str = "tabular",
22 resource_prefix: str | None = None,
23 options: list | None = None,
24 state: TableState | None = None,
25 **props: Any,
26 ) -> None:
27 super().__init__(**props)
28 self.current = current or "tabular"
29 self.resource_prefix = resource_prefix or ""
30 self.options = options or [
31 ("tabular", "Tabular"),
32 ("grid", "Grid"),
33 ("calendar", "Calendar"),
34 ("stacked", "Stacked"),
35 ]
36 # Optional TableState instance for generating HTMX attrs
37 self.state = state
39 def render(self) -> Any:
40 from lexigram.ui import get_icon
42 # Icon mapping
43 icon_map = {
44 "tabular": "table",
45 "grid": "grid",
46 "calendar": "calendar",
47 "stacked": "list",
48 }
50 # Button with dropdown list; for simplicity, use inline links with HTMX attributes
51 items = []
52 for value, label in self.options:
53 attrs = {
54 # Use HTMXAttrs for consistent attribute generation
55 "class": "block px-3 py-2 text-sm text-foreground hover:bg-muted dark:text-foreground dark:hover:bg-muted flex items-center gap-2",
56 "hx_on": f"click:console.log('view:{value}')",
57 # Simple serialized JS to update the trigger icon on click
58 "onclick": "let svg = this.querySelector('svg').cloneNode(true); svg.setAttribute('class', 'h-4 w-4 text-muted-foreground dark:text-foreground'); this.closest('details').querySelector('summary span').innerHTML = ''; this.closest('details').querySelector('summary span').appendChild(svg); this.closest('details').open = false;",
59 }
61 # Generate HTMX attrs using the new factory
62 if self.state:
63 updated_state = self.state.with_view(value)
64 htmx_attrs = HTMXAttrs.for_full_refresh(
65 updated_state,
66 self.resource_prefix,
67 push_url=True,
68 )
69 # Convert hx-* to hx_* for element builder
70 for k, v in htmx_attrs.items():
71 attrs[k.replace("-", "_")] = v
72 else:
73 # Fallback if no state provided
74 attrs.update(
75 {
76 "hx_get": f"{self.resource_prefix.rstrip('/')}/?data_view={value}",
77 "hx_target": Zones.TABLE.selector,
78 "hx_swap": "outerHTML",
79 "hx_params": "none",
80 "hx_push_url": "false",
81 },
82 )
84 icon_name = icon_map.get(value, "table")
85 icon_el = get_icon(
86 icon_name,
87 size="h-4 w-4 text-muted-foreground group-hover:text-muted-foreground",
88 )
90 if value == self.current:
91 # Mark selected: Visual indicator + disable interaction
92 attrs["aria-current"] = "true"
93 attrs["class"] = (
94 "block px-3 py-2 text-sm font-medium bg-muted text-foreground cursor-default pointer-events-none flex items-center gap-2"
95 )
97 # Active icon style
98 icon_el = get_icon(icon_name, size="h-4 w-4 text-primary-500")
100 # Remove navigation props
101 attrs.pop("href", None)
102 attrs.pop("hx_get", None)
103 attrs.pop("hx_target", None)
104 attrs.pop("hx_swap", None)
105 attrs.pop("hx_include", None)
106 attrs.pop("hx_push_url", None)
107 attrs.pop("hx_boost", None)
108 attrs.pop("hx_on", None)
109 attrs.pop("onclick", None) # No need to update on click if disabled
111 label = f"{label}"
113 items.append(el("a", icon_el, label, **attrs))
115 # Use native <details>/<summary> for toggle behavior (works without JS)
117 # Build a non-interactive trigger inside the summary so clicks toggle <details> reliably
118 # Show active icon in trigger
119 current_icon_name = icon_map.get(self.current, "table")
120 trigger_icon = get_icon(
121 current_icon_name,
122 size="h-4 w-4 text-muted-foreground dark:text-foreground",
123 )
125 trigger_el = el(
126 "span",
127 trigger_icon,
128 class_="inline-flex items-center justify-center p-1 rounded-md hover:bg-muted dark:hover:bg-card transition-colors h-8 w-8",
129 role="button",
130 tabindex="0",
131 aria_label=f"Current view: {self.current.title()}",
132 **{
133 "aria-haspopup": "menu",
134 },
135 )
137 return el(
138 "div",
139 el(
140 "details",
141 el(
142 "summary",
143 trigger_el,
144 class_="list-none cursor-pointer",
145 ),
146 el(
147 "div",
148 *items,
149 # Changed from right-0 to left-0 because this component is usually on the left side of the toolbar
150 # This prevents it from expanding leftwards into the sidebar.
151 # Increased z-index to 100 to avoid being hidden by sidebar
152 class_="absolute left-0 mt-2 w-40 bg-card rounded-md shadow-lg ring-1 ring-border z-[100] py-1 focus:outline-none origin-top-left",
153 ),
154 class_="relative inline-block",
155 ),
156 # Hidden marker for server-side presence detection
157 el("span", "", class_="hidden view-switcher-marker"),
158 class_="view-switcher inline-block text-sm",
159 )