Coverage for src / lexigram / ui / molecules / dropdown.py: 100%

24 statements  

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

1from __future__ import annotations 

2 

3from typing import Any 

4 

5from lexigram.ui.core.base import Component, el 

6 

7 

8class Dropdown(Component): 

9 """ 

10 A refined dropdown menu component with positioning control. 

11 """ 

12 

13 def __init__( 

14 self, 

15 trigger: str | Any, 

16 items: list[Any], 

17 position: str = "right", 

18 direction: str = "down", 

19 **props, 

20 ) -> None: 

21 super().__init__( 

22 trigger=trigger, 

23 items=items, 

24 position=position, 

25 direction=direction, 

26 **props, 

27 ) 

28 self.trigger = trigger 

29 self.items = items 

30 self.position = position 

31 self.direction = direction 

32 

33 def render(self) -> Any: 

34 # Determine alignment classes 

35 origin_cls = "origin-top-right right-0" 

36 if self.position == "left": 

37 origin_cls = "origin-top-left left-0" 

38 

39 # Determine direction classes 

40 mt_cls = "mt-2" 

41 origin_dir = "origin-top" 

42 if self.direction == "up": 

43 mt_cls = "mb-2 bottom-full" 

44 origin_dir = "origin-bottom" 

45 if self.position == "right": 

46 origin_cls = "origin-bottom-right right-0" 

47 else: 

48 origin_cls = "origin-bottom-left left-0" 

49 

50 menu_items = [ 

51 el("div", item, {"role": "menuitem", "tabindex": "-1", "class": "cursor-pointer"}) 

52 for item in self.items 

53 ] 

54 

55 return el( 

56 "div", 

57 { 

58 "x-data": "{ open: false, focusedIndex: -1 }", 

59 "class": "relative inline-block text-left w-full", 

60 "x-on:keydown.escape.prevent": "open = false", 

61 "x-on:keydown.down.prevent": "focusedIndex = Math.min(focusedIndex + 1, " + str(len(self.items) - 1) + "); $nextTick(() => $el.querySelector('[role=menuitem]:nth-child(' + (focusedIndex + 1) + ')')?.focus())", 

62 "x-on:keydown.up.prevent": "focusedIndex = Math.max(focusedIndex - 1, 0); $nextTick(() => $el.querySelector('[role=menuitem]:nth-child(' + (focusedIndex + 1) + ')')?.focus())", 

63 }, 

64 # Trigger 

65 el( 

66 "div", 

67 { 

68 "x-ref": "trigger", 

69 "x-on:click": "open = !open", 

70 "x-on:click.outside": "open = false", 

71 ":aria-expanded": "open", 

72 "class": "w-full", 

73 "tabindex": "0", 

74 "role": "button", 

75 "x-on:keydown.enter.prevent": "open = !open", 

76 "x-on:keydown.space.prevent": "open = !open", 

77 }, 

78 self.trigger, 

79 ), 

80 # Menu 

81 el( 

82 "div", 

83 { 

84 "x-show": "open", 

85 "x-transition:enter": "transition ease-out duration-100", 

86 "x-transition:enter-start": "transform opacity-0 scale-95", 

87 "x-transition:enter-end": "transform opacity-100 scale-100", 

88 "x-transition:leave": "transition ease-in duration-75", 

89 "x-transition:leave-start": "transform opacity-100 scale-100", 

90 "x-transition:leave-end": "transform opacity-0 scale-95", 

91 "class": f"absolute {origin_cls} {mt_cls} {origin_dir} z-50 w-full min-w-[14rem] rounded-md bg-popover text-popover-foreground shadow-lg ring-1 ring-border focus:outline-none", 

92 "role": "menu", 

93 "x-cloak": True, 

94 }, 

95 el("div", {"class": "py-1", "role": "none"}, *menu_items), 

96 ), 

97 )