__init__.py
1 # SPDX-License-Identifier: MIT 2 # 3 # Copyright (c) 2021 The Anvil Extras project team members listed at 4 # https://github.com/anvilistas/anvil-extras/graphs/contributors 5 # 6 # This software is published at https://github.com/anvilistas/anvil-extras 7 8 from anvil import LinearPanel as _LinearPanel 9 from anvil import Link as _Link 10 from anvil import TextBox as _TextBox 11 from anvil.js import get_dom_node as _get_dom_node 12 from anvil.js.window import document as _document 13 from anvil.js.window import jQuery as _S 14 from anvil.js.window import window as _window 15 16 from ..utils._component_helpers import _html_injector 17 from ._anvil_designer import AutocompleteTemplate 18 19 __version__ = "3.1.0" 20 21 22 _html_injector.css( 23 """ 24 .anvil-role-ae-autocomplete { 25 padding: 0 !important; 26 } 27 .anvil-role-ae-autocomplete { 28 position: absolute; 29 transform: scaleX(1) scaleY(1); 30 opacity: 1; 31 transform-origin: 0px 0px; 32 overflow-y: auto; 33 min-width: 100px; 34 max-height: 300px; 35 transition: all 100ms ease; 36 background-color: #fff; 37 border-radius: 2px; 38 z-index: 3001; 39 box-shadow: 0 2px 2px 0 rgb(0 0 0 / 14%), 0 3px 1px -2px rgb(0 0 0 / 12%), 0 1px 5px 0 rgb(0 0 0 / 20%); 40 } 41 .anvil-role-ae-autocomplete.visible-false, .anvil-role-ae-autocomplete.anvil-visible-false { 42 transform: scaleX(0) scaleY(0); 43 opacity: 0; 44 display: block !important; 45 transition: all 200ms ease; 46 } 47 .anvil-role-ae-autocomplete a { 48 padding: 7px 16px; 49 } 50 .anvil-role-ae-autocomplete a:hover, .anvil-role-ae-autocomplete a.anvil-role-ae-autocomplete-active { 51 background-color: #eee; 52 } 53 """ 54 ) 55 56 57 class Autocomplete(AutocompleteTemplate): 58 def __init__(self, **properties): 59 self._active_nodes = [] 60 self._active = None 61 self._active_index = -1 62 self._link_height = 0 63 self._nodes = {} 64 self._filter_mode = None 65 self._filter_fn = self._filter_contains 66 67 self.init_components(**properties) 68 69 self._lp = _LinearPanel( 70 role="ae-autocomplete", 71 spacing_above="none", 72 spacing_below="none", 73 visible=False, 74 ) 75 self._lp_node = _get_dom_node(self._lp) 76 77 dom_node = self._dom_node = _get_dom_node(self) 78 # use capture for keydown so we can get the event before anvil does 79 dom_node.addEventListener("keydown", self._on_keydown, True) 80 dom_node.addEventListener("input", self._on_input) 81 dom_node.addEventListener("focus", self._on_focus, True) 82 dom_node.addEventListener("blur", self._on_blur) 83 self.set_event_handler("x-popover-init", self._handle_popover) 84 self.set_event_handler("x-popover-destroy", self._handle_popover) 85 86 # ensure the same method is passed to $(window).off('resize') 87 self._reset_position = self._reset_position 88 89 ###### PRIVATE METHODS ###### 90 @staticmethod 91 def _filter_contains(text, search): 92 return text.lower().find(search) 93 94 @staticmethod 95 def _filter_startswith(text, search): 96 return 0 if text.lower().startswith(search) else -1 97 98 def _populate(self): 99 prev_active = self._active 100 self._reset_autocomplete() 101 self._lp.clear() 102 103 search_term = self.text.lower() 104 if not search_term and not self.suggest_if_empty: 105 return 106 107 n = len(search_term) 108 filter_fn = self._filter_fn 109 110 def get_node_with_emph(text): 111 i = filter_fn(text, search_term) 112 if i == -1: 113 return False 114 node = self._get_node(text) 115 if n: 116 node.tag.innerHTML = ( 117 text[:i] + "<b>" + text[i : i + n] + "</b>" + text[i + n :] 118 ) 119 return node 120 121 nodes = filter(None, map(get_node_with_emph, self.suggestions)) 122 for node in nodes: 123 if node.parent is not None: 124 print(f"Warning: you have duplicate suggestions - ignoring {node.text}") 125 continue 126 self._lp.add_component(node) 127 self._active_nodes.append(node) 128 129 self._lp.visible = bool(self._active_nodes) 130 try: 131 self._active_index = self._active_nodes.index(prev_active) 132 self._active = prev_active 133 self._active.role = "ae-autocomplete-active" 134 except ValueError: 135 pass 136 137 def _get_node(self, text): 138 link = self._nodes.get(text) 139 if link: 140 link.text = link.text 141 return link 142 143 link = _Link(text=text, spacing_above="none", spacing_below="none") 144 link.set_event_handler("click", self._set_text) 145 l_node = _get_dom_node(link) 146 link.tag = l_node.querySelector("div") 147 # this stops the lost_focus event firing when a suggestion is clicked 148 l_node.addEventListener("mousedown", lambda e: e.preventDefault()) 149 150 self._nodes[text] = link 151 return link 152 153 def _set_text(self, sender=None, *e, **e_args): 154 if sender is not None: 155 self.text = sender.text 156 self._reset_autocomplete() 157 self.raise_event("suggestion_clicked") 158 elif self._active is not None: 159 self.text = self._active.text 160 self._reset_autocomplete() 161 else: 162 self._reset_autocomplete() 163 164 def _reset_autocomplete(self): 165 if self._active is not None: 166 self._active.role = None 167 self._active = None 168 self._lp.visible = False 169 self._active_nodes = [] 170 self._active_index = -1 171 self._lp_node.scrollTop = 0 172 173 def _reset_position(self, *e): 174 rect = self._dom_node.getBoundingClientRect() 175 root_rect = _document.documentElement.getBoundingClientRect() 176 body_rect = _document.body.getBoundingClientRect() 177 fixed_offset_top = body_rect.top - root_rect.top 178 lp_node = self._lp_node 179 lp_node.style.left = f"{rect.left - body_rect.left}px" 180 lp_node.style.top = f"{rect.bottom - body_rect.top + fixed_offset_top + 5}px" 181 lp_node.style.width = f"{rect.width}px" 182 183 ###### INTERNAL EVENTS ###### 184 def _on_keydown(self, e): 185 key = getattr(e, "key", None) 186 if key in ("ArrowDown", "ArrowUp"): 187 e.preventDefault() 188 elif key == "Enter": 189 self._set_text() 190 return 191 elif key == "Escape": 192 self._reset_autocomplete() 193 return 194 else: 195 return 196 197 try: 198 if key == "ArrowDown": 199 i = min(self._active_index + 1, len(self._active_nodes) - 1) 200 new_active = self._active_nodes[i] 201 else: 202 i = max(self._active_index - 1, -1) 203 new_active = None if i == -1 else self._active_nodes[i] 204 except IndexError: 205 return 206 207 self._active_index = i 208 if self._active is not None: 209 self._active.role = None 210 if new_active is not None: 211 new_active.role = "ae-autocomplete-active" 212 self._link_height = ( 213 self._link_height or _get_dom_node(new_active).clientHeight 214 ) 215 self._lp_node.scrollTop = max(0, self._link_height * (i - 4)) 216 217 self._active = new_active 218 219 def _on_input(self, e): 220 """This method is called when the text in this text box is edited""" 221 self._populate() 222 223 def _on_focus(self, e): 224 """This method is called when the TextBox gets focus""" 225 self._reset_position() 226 self._populate() 227 228 def _on_blur(self, e): 229 """This method is called when the TextBox loses focus""" 230 self._reset_autocomplete() 231 232 def _on_show(self, **e_args): 233 """This method is called when the TextBox is shown on the screen""" 234 _document.body.appendChild(self._lp_node) 235 _S(_window).on("resize", self._reset_position) 236 237 def _on_hide(self, **e_args): 238 """This method is called when the TextBox is removed from the screen""" 239 try: 240 self._lp_node.remove() 241 except Exception: 242 pass 243 _S(_window).off("resize", self._reset_position) 244 self._nodes = {} 245 246 def _handle_popover(self, init_node, **event_args): 247 init_node(self._lp_node) 248 249 ##### Properties ###### 250 @property 251 def suggestions(self): 252 return self._data or [] 253 254 @suggestions.setter 255 def suggestions(self, val): 256 self._data = val 257 if self._active is not None: 258 self._populate() 259 260 @property 261 def filter_mode(self): 262 return self._filter_mode 263 264 @filter_mode.setter 265 def filter_mode(self, value): 266 self._filter_mode = value 267 if value == "startswith": 268 self._filter_fn = self._filter_startswith 269 else: 270 self._filter_fn = self._filter_contains 271 272 text = _TextBox.text 273 placeholder = _TextBox.placeholder 274 spacing_above = _TextBox.spacing_above 275 spacing_below = _TextBox.spacing_below 276 enabled = _TextBox.enabled 277 foreground = _TextBox.foreground 278 background = _TextBox.background 279 visible = _TextBox.visible 280 tag = _TextBox.tag