/ client_code / Autocomplete / __init__.py
__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