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

1from __future__ import annotations 

2 

3from typing import TYPE_CHECKING, Any 

4 

5from lexigram.admin.ui.htmx_attrs import HTMXAttrs 

6from lexigram.ui import Component, Zones, el 

7 

8if TYPE_CHECKING: 

9 from lexigram.ui.state import TableState 

10 

11 

12class ViewSwitcher(Component): 

13 """Simple view switcher dropdown for DataTable. 

14 

15 Renders a compact dropdown listing available views and emits HTMX 

16 requests to the resource with `data_view` query param. 

17 """ 

18 

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 

38 

39 def render(self) -> Any: 

40 from lexigram.ui import get_icon 

41 

42 # Icon mapping 

43 icon_map = { 

44 "tabular": "table", 

45 "grid": "grid", 

46 "calendar": "calendar", 

47 "stacked": "list", 

48 } 

49 

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 } 

60 

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 ) 

83 

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 ) 

89 

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 ) 

96 

97 # Active icon style 

98 icon_el = get_icon(icon_name, size="h-4 w-4 text-primary-500") 

99 

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 

110 

111 label = f"{label}" 

112 

113 items.append(el("a", icon_el, label, **attrs)) 

114 

115 # Use native <details>/<summary> for toggle behavior (works without JS) 

116 

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 ) 

124 

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 ) 

136 

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 )