__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  import anvil.js as _js
  9  from anvil import HtmlPanel as _HtmlPanel
 10  from anvil.js.window import document as _document
 11  
 12  from ..popover import pop, popover
 13  from ..utils._component_helpers import _css_length, _html_injector, _spacing_property
 14  from ._anvil_designer import MultiSelectDropDownTemplate
 15  from .DropDown import DropDown
 16  from .Option import Option
 17  
 18  __version__ = "3.1.0"
 19  
 20  _css = """
 21  .anvil-role-ae-ms-btn > button {
 22      display: flex;
 23      align-items: center;
 24      justify-content: space-between;
 25      width: 100%;
 26  }
 27  
 28  .anvil-role-ae-ms-btn > button > span {
 29      text-overflow: ellipsis;
 30      white-space: nowrap !important;
 31      overflow: hidden;
 32  }
 33  
 34  /* dropdown */
 35  .ae-ms-dd {
 36      display: flex;
 37      flex-direction: column;
 38      height: 100%;
 39  }
 40  
 41  .ae-ms-select-all button > span {
 42      text-wrap: nowrap !important;
 43  }
 44  
 45  .ae-ms-options {
 46      min-height: 0;
 47  }
 48  
 49  .ae-ms-options:focus-visible {
 50      outline: none;
 51  }
 52  .ae-ms-options > div {
 53      height: 100%
 54  }
 55  .ae-ms-options ul {
 56      display: flex;
 57      flex-direction: column;
 58      height: 100%;
 59      overflow: scroll;
 60  }
 61  
 62  .ae-ms-options a[data-disabled] {
 63      opacity: 0.5;
 64      cursor: not-allowed;
 65      pointer-events: none;
 66  }
 67  .ae-ms-options a[data-disabled] * {
 68      pointer-events: none;
 69  }
 70  
 71  .ae-ms-options div[data-divider] {
 72      height: 1px;
 73      margin: 9px 0;
 74      overflow: hidden;
 75      background-color: #e5e5e5;
 76  }
 77  
 78  .ae-ms-options .anvil-panel-section-gutter,
 79  .ae-ms-options .flow-panel-gutter,
 80  .ae-ms-options .anvil-flow-panel-gutter,
 81  .ae-ms-options .anvil-panel-col,
 82  .ae-ms-options .flow-panel-item,
 83  .ae-ms-options .anvil-flow-panel-item {
 84      margin: 0 !important;
 85  }
 86  .ae-ms-options .anvil-panel-col,
 87  .ae-ms-options .flow-panel-item,
 88  .ae-ms-options .anvil-flow-panel-item {
 89      padding: 0 !important;
 90  }
 91  .ae-ms-options .flow-panel-gutter,
 92  .ae-ms-options .anvil-flow-panel-gutter {
 93      gap: 8px;
 94  }
 95  
 96  .ae-ms-options a.anvil-role-ae-ms-option {
 97      color: var(--ae-ms-option-text, #333333);
 98      padding: 2px 0;
 99  }
100  
101  .ae-ms-options a.anvil-role-ae-ms-option:hover:not(.anvil-role-ae-ms-option-active) {
102      background-color: var(--ae-ms-option-bg-hover, #e8e8e8);
103  }
104  
105  .ae-ms-options a.anvil-role-ae-ms-option-active {
106      background-color: var(--ae-ms-option-bg-active, #337ab7);
107      color: var(--ae-ms-option-text-active, #fff);
108  }
109  
110  .anvil-role-ae-ms-option-label span {
111      white-space: nowrap !important;
112      padding: 0 !important;
113  }
114  
115  .anvil-role-ae-ms-option-subtext span {
116      white-space: nowrap !important;
117      font-size: 80%;
118      padding: 0 !important;
119      color: var(--ae-ms-option-subtext, #777);
120  }
121  .anvil-role-ae-ms-option-active .anvil-role-ae-ms-option-subtext span {
122      color: var(--ae-ms-option-subtext-active, rgba(255,255,255,.5));
123  }
124  
125  """
126  _html_injector.css(_css)
127  
128  # TODO
129  # - [x] add support for dividers
130  # - [x] add support for icons
131  # - [x] add support for subtext
132  # - [x] add support for title
133  # - [x] add support for disabled
134  # - [x] make the dropwdown conatiner a compnent in it's own right - might even be a custom component
135  # - [x] add support for select all buttons
136  #       - [x] we probably need to wrap the lp in a container and add the button to that
137  #       - [x] only works in multiple mode
138  # - [x] add arrow key support
139  #       - [x] active role
140  #       - [x] when filtering is enabled - ensure the arrow keys work
141  #         - needs a different approach - can't just do the focus thing
142  # - [x] add tab key support
143  # - [x] Consider whether we need the underlying select and options (probably not)
144  #       - No
145  # - [x] search box support
146  #       - [x] focus the search box when the search box is added to the page
147  # - [x] ensure visible works as expected - i.e. the popover should be hidden when we're not visible
148  # - [x] support single select dropdown
149  #       - when an option is selected all other options are hidden
150  #       - This might be why it's useful to use the underlying option component, because then the browser is responsible for this logic
151  # - [x] support the various width options
152  #       - [ ] fit needs to also change the width of the dd to the max
153  # - [x] support the selected text option
154  # - [x] support the placeholder text
155  # - [x] styling and roles
156  # - [ ] go through each property and make sure it's working as expected
157  # - [ ] test multi select inside a popover
158  # - [ ] stop the focus within when enable fitler is false and we hit an arrow key
159  
160  _defaults = {
161      "align": "left",
162      "placeholder": "None Selected",
163      "enable_filtering": False,
164      "multiple": True,
165      "enabled": True,
166      "items": None,
167      "spacing_below": "small",
168      "spacing_above": "small",
169      "enable_select_all": False,
170      "width": "",
171      "visible": True,
172  }
173  
174  
175  def _props_property(prop, setter):
176      def getprop(self):
177          return self._props[prop]
178  
179      def setprop(self, val):
180          self._props[prop] = val
181          setter(self, val)
182  
183      return property(getprop, setprop, None, prop)
184  
185  
186  class MultiSelectDropDown(MultiSelectDropDownTemplate):
187      def __init__(self, **properties):
188          self._init = False
189          self._dom_node = _js.get_dom_node(self)
190          self._invalid = []
191          self._options = []
192          self._total = 0
193          self._props = props = _defaults | properties
194          props["items"] = props["items"] or []
195  
196          self._dd_width = 0
197          self._dd = DropDown()
198          self._dd.add_event_handler("change", self._change)
199          popover(
200              self._select_btn,
201              self._dd,
202              placement="bottom-start",
203              arrow=False,
204              delay=0,
205              animation=False,
206              trigger="manual",
207              max_width="fit-content",
208          )
209  
210          selected = props.pop("selected", ())
211  
212          self.init_components(**props)
213          self.set_event_handler("x-popover-init", self._mk_popover)
214          self.set_event_handler("x-popover-destroy", self._mk_popover)
215          self._dd.set_event_handler(
216              "x-popover-show", lambda **e: self.raise_event("opened")
217          )
218          self._dd.set_event_handler(
219              "x-popover-hide", lambda **e: self.raise_event("closed")
220          )
221  
222          self._init = True
223          self.selected = selected
224  
225      def format_selected_text(self, count, total):
226          if count > 3:
227              return f"{count} items selected"
228          return ", ".join(opt.title or opt.key for opt in self._options if opt.selected)
229  
230      ##### PROPERTIES #####
231      @property
232      def width(self):
233          return self._props.get("width")
234  
235      @width.setter
236      def width(self, val):
237          self._props["width"] = val
238          self._dd._dom_node.style.minWidth = ""
239  
240          if val == "auto":
241              self._select_btn.width = self._dd_width
242          elif val == "fit":
243              self._select_btn.width = "fit-content"
244              self._dd._dom_node.style.minWidth = ""
245          elif not val:
246              self._select_btn.width = 220
247              self._dd._dom_node.style.minWidth = "192px"
248          else:
249              self._select_btn.width = val
250              self._dd._dom_node.style.minWidth = _css_length(val)
251          # We might want to change this
252          # but if we do we need the btn to be on the screen to calculate the width
253          # and we'd probably need to have a resize observer to change the width of the _dd element
254  
255      @property
256      def align(self):
257          return self._select_btn.align
258  
259      @align.setter
260      def align(self, val):
261          self._select_btn.align = val
262  
263      @property
264      def items(self):
265          return self._props["items"]
266  
267      @items.setter
268      def items(self, value):
269          self._props["items"] = value
270          self._close()
271          selected = self.selected + self._invalid
272  
273          options = Option.from_items(value)
274  
275          self._dd.options = self._options = options
276          self._calc_dd_width()
277          self._total = sum(1 for opt in options if not opt.is_divider)
278          self.selected = selected
279          if self._init:
280              self.width = self.width
281  
282      @property
283      def selected_keys(self):
284          return [opt.key for opt in self._options if opt.selected]
285  
286      @property
287      def selected(self):
288          return [opt.value for opt in self._options if opt.selected]
289  
290      @selected.setter
291      def selected(self, values):
292          if not self._init:
293              return
294  
295          FOUND = object()
296  
297          if not isinstance(values, (list, tuple)):
298              values = [values]
299          else:
300              values = list(values)
301  
302          multiple = self.multiple
303          first = True
304  
305          for opt in self._options:
306              try:
307                  idx = values.index(opt.value)
308              except ValueError:
309                  opt.selected = False
310              else:
311                  values[idx] = FOUND
312                  opt.selected = True if multiple else first
313                  first = False
314  
315          self._invalid = [val for val in values if val is not FOUND]
316          self._change(raise_event=False)
317  
318      @property
319      def placeholder(self):
320          return self._props.get("placeholder", "")
321  
322      @placeholder.setter
323      def placeholder(self, val):
324          self._props["placeholder"] = val
325          if not self.selected_keys:
326              self._select_btn.text = val
327  
328      @property
329      def enabled(self):
330          return self._select_btn.enabled
331  
332      @enabled.setter
333      def enabled(self, val):
334          self._select_btn.enabled = val
335          if not val:
336              self._close()
337  
338      @property
339      def enable_filtering(self):
340          return self._dd.enable_filtering
341  
342      @enable_filtering.setter
343      def enable_filtering(self, val):
344          self._dd.enable_filtering = val
345  
346      @property
347      def multiple(self):
348          return self._dd.multiple
349  
350      @multiple.setter
351      def multiple(self, val):
352          self._dd.multiple = val
353          self.selected = self.selected
354  
355      @property
356      def enable_select_all(self):
357          return self._dd.enable_select_all
358  
359      @enable_select_all.setter
360      def enable_select_all(self, val):
361          self._dd.enable_select_all = val
362  
363      tag = _HtmlPanel.tag
364      visible = _HtmlPanel.visible
365      spacing_above = _spacing_property("above")
366      spacing_below = _spacing_property("below")
367  
368      ##### PRIVATE Functions #####
369  
370      def _calc_dd_width(self):
371          dd_node = self._dd._dom_node
372          width = dd_node.style.width
373          min_width = dd_node.style.minWidth
374          dd_node.style.width = "fit-content"
375          _document.body.appendChild(dd_node)
376          self._dd_width = dd_node.offsetWidth + 28
377          dd_node.remove()
378          dd_node.style.width = width
379          dd_node.style.minWidth = min_width
380  
381      def _change(self, raise_event=True, **event_args):
382          keys = self.selected_keys
383          if not keys:
384              self._select_btn.text = self.placeholder
385          else:
386              self._select_btn.text = self.format_selected_text(len(keys), self._total)
387          if raise_event:
388              self.raise_event("change")
389  
390      def _mk_popover(self, init_node, **event_args):
391          init_node(self._dd)
392  
393      def _open(self, **e):
394          if not pop(self._select_btn, "shown"):
395              pop(self._select_btn, "show")
396              # invalidate these since the user has interacted with the component
397              self._invalid = []
398  
399      def _close(self, **e):
400          if pop(self._select_btn, "shown"):
401              pop(self._select_btn, "hide")
402  
403      def _toggle(self, **e):
404          if not pop(self._select_btn, "shown"):
405              self._open()
406          else:
407              self._close()