__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.js import get_dom_node
  9  from anvil.js.window import setTimeout
 10  
 11  from ...popover import pop
 12  from ..Option import Option
 13  from ._anvil_designer import DropDownTemplate
 14  
 15  __version__ = "3.1.0"
 16  
 17  
 18  class DropDown(DropDownTemplate):
 19      def __init__(self, **properties):
 20          self._dom_node = get_dom_node(self)
 21          self._dom_node.style.height = "100%"
 22          self._props = properties
 23          self.dd_node = self.dom_nodes["ae-ms-dd"]
 24          self.options_node = self.dom_nodes["ae-ms-options"]
 25          self.init_components(**properties)
 26          self.select_all_btn.role = "ae-ms-select-btn"
 27          self.deselect_all_btn.role = "ae-ms-select-btn"
 28          self.filter_box.role = "ae-ms-filter"
 29          self.dd_node.addEventListener("keydown", self._on_keydown)
 30          self._no_options = Option()
 31          self._no_options.visible = False
 32          self.options_panel.add_component(self._no_options)
 33  
 34      @property
 35      def options(self):
 36          return self._props.get("options", [])
 37  
 38      @options.setter
 39      def options(self, val):
 40          val = val or []
 41          self._props["options"] = val
 42          self.options_panel.clear()
 43          for opt in val:
 44              self.options_panel.add_component(opt)
 45              opt.add_event_handler("click", self._on_option_clicked)
 46  
 47          self.options_panel.add_component(self._no_options)
 48          self._no_options.visible = False
 49  
 50      @property
 51      def enable_filtering(self):
 52          return self._props.get("enable_filtering", False)
 53  
 54      @enable_filtering.setter
 55      def enable_filtering(self, val):
 56          self._props["enable_filtering"] = val
 57          self.filter_box.visible = val
 58  
 59      @property
 60      def enable_select_all(self):
 61          return self._props.get("enable_select_all", False)
 62  
 63      @enable_select_all.setter
 64      def enable_select_all(self, val):
 65          self._props["enable_select_all"] = val
 66          self.select_all_flow.visible = self.multiple and val
 67  
 68      @property
 69      def multiple(self):
 70          return self._props.get("multiple", False)
 71  
 72      @multiple.setter
 73      def multiple(self, val):
 74          self._props["multiple"] = val
 75          if val:
 76              self.enable_select_all = self.enable_select_all
 77  
 78      def _close(self):
 79          pop(self.popper, "hide")
 80  
 81      def _on_filter_show(self, **event_args):
 82          # because of weird way we are hacking the show events in popovers
 83          setTimeout(self.filter_box.focus)
 84  
 85      def _on_filter_hide(self, **event_args):
 86          self.filter_box.text = ""
 87          for option in self.options:
 88              option.visible = True
 89  
 90          self._no_options.visible = False
 91  
 92      def _on_filter_change(self, **event_args):
 93          term = self.filter_box.text or ""
 94          term = term.lower()
 95          num_results = 0
 96          for option in self.options:
 97              if option.is_divider:
 98                  option.visible = not term
 99              else:
100                  visible = term in option.key.lower() or term in option.subtext.lower()
101                  option.visible = visible
102                  num_results += visible
103  
104          active_idx = self._get_active_idx()
105          if active_idx != -1:
106              self.options[active_idx].active = False
107  
108          no_options = not num_results and term
109          self._no_options.visible = no_options
110          if no_options:
111              self._no_options.label.text = f"No results matched {term!r}"
112  
113          if not term:
114              return
115  
116          first_idx = self._get_next_idx(-1)
117          if first_idx != -1:
118              self.options[first_idx].active = True
119  
120      def _on_filter_enter(self, **e):
121          active_idx = self._get_active_idx()
122          if active_idx != -1:
123              self.options[active_idx].click()
124  
125      def _on_select_all(self, **event_args):
126          for option in self.options:
127              option.selected = True
128          self._on_focus()
129          self.raise_event("change")
130  
131      def _on_deselect_all(self, **event_args):
132          for option in self.options:
133              option.selected = False
134          self._on_focus()
135          self.raise_event("change")
136  
137      def _on_show(self, **event_args):
138          if self.enable_filtering:
139              return
140  
141          def focus():
142              self.options_node.tabIndex = 0
143              self.options_node.focus()
144              self.options_node.tabIndex = -1
145  
146          setTimeout(focus)
147  
148      def _on_focus(self, *args, **kws):
149          setTimeout(self.filter_box.focus)
150  
151      def _get_active_idx(self):
152          for i, opt in enumerate(self.options):
153              if opt.is_divider:
154                  continue
155              if opt.active:
156                  return i
157          return -1
158  
159      def _get_next_idx(self, active_idx, dir=1, pred=None):
160          num_options = len(self.options)
161          if active_idx == -1 and dir == -1:
162              active_idx = 0
163  
164          nxt_idx = (active_idx + dir) % num_options
165  
166          for i in range(nxt_idx, dir * num_options + nxt_idx, dir):
167              idx = i % num_options
168              nxt = self.options[idx]
169              if nxt.is_divider:
170                  continue
171              if not nxt.visible or nxt.disabled:
172                  continue
173              if pred is None:
174                  return idx
175              cond = pred(nxt)
176              if cond:
177                  return idx
178  
179          return -1
180  
181      def _on_keydown(self, e):
182          key = e.key
183          if key == "Tab":
184              key = "ArrowUp" if e.shiftKey else "ArrowDown"
185  
186          is_input = e.target.nodeName == "INPUT"
187  
188          if key == "Escape":
189              self._close()
190              get_dom_node(self.popper).firstElementChild.focus()
191              # TODO focus the popper
192  
193          elif key == " ":
194              if not is_input:
195                  e.preventDefault()
196  
197          elif key == "Enter":
198              e.preventDefault()
199              self._on_filter_enter()
200  
201          elif key == "ArrowDown" or key == "ArrowUp":
202              e.preventDefault()
203  
204              dir = 1 if key == "ArrowDown" else -1
205              active_idx = self._get_active_idx()
206              if active_idx != -1:
207                  self.options[active_idx].active = False
208              next_idx = self._get_next_idx(active_idx, dir)
209              if next_idx != -1:
210                  self.options[next_idx].active = True
211  
212          elif key.isalpha():
213              if is_input:
214                  return
215              key = key.lower()
216              active_idx = self._get_active_idx()
217              next_idx = self._get_next_idx(
218                  active_idx, dir=1, pred=lambda opt: opt.key.lower().startswith(key)
219              )
220              if next_idx != -1:
221                  if active_idx != -1:
222                      self.options[active_idx].active = False
223                  self.options[next_idx].active = True
224  
225      def _on_option_clicked(self, sender, **e):
226          multiple = self.multiple
227          for opt in self.options:
228              if not multiple:
229                  opt.selected = opt is sender
230              opt.active = opt is sender and not sender.disabled
231  
232          if sender.disabled:
233              return
234  
235          self.raise_event("change")
236  
237          if not self.multiple:
238              self._close()