/ client_code / popover.py
popover.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  #
  9  # for more information visit the w3 bootstrap popover page
 10  # https://www.w3schools.com/bootstrap4/bootstrap_ref_js_popover.asp
 11  #
 12  # or the bootstrap popover page for v 3.4.1
 13  # https://getbootstrap.com/docs/3.4/javascript/#popovers
 14  #
 15  
 16  import anvil as _anvil
 17  import anvil.js
 18  from anvil.js.window import WeakMap as _WeakMap
 19  from anvil.js.window import document as _document
 20  from anvil.js.window import window as _W
 21  
 22  from . import fui
 23  from .utils._component_helpers import _html_injector
 24  from .utils._component_helpers import walk as _walk
 25  from .utils._deprecated import deprecated as _deprecated
 26  from .utils._warnings import warn as _warn
 27  
 28  __version__ = "3.1.0"
 29  
 30  __all__ = [
 31      "popover",
 32      "pop",
 33      "dismiss_on_outside_click",
 34      "dismiss_on_scroll",
 35      "set_default_max_width",
 36      "set_default_container",
 37  ]
 38  
 39  _popper_map = _WeakMap()
 40  _visible_popovers = {}
 41  
 42  
 43  css = """
 44  .ae-popover {
 45      z-index: 1060;
 46      max-width: 276px;
 47      padding: 1px;
 48      font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
 49      font-style: normal;
 50      font-weight: 400;
 51      line-height: 1.42857143;
 52      line-break: auto;
 53      text-align: start;
 54      text-decoration: none;
 55      text-shadow: none;
 56      text-transform: none;
 57      letter-spacing: normal;
 58      word-break: normal;
 59      word-spacing: normal;
 60      word-wrap: normal;
 61      white-space: normal;
 62      font-size: 14px;
 63      background-color: var(--ae-popover-bg, #fff);
 64      background-clip: padding-box;
 65      border: 1px solid var(--ae-popover-border, rgba(0, 0, 0, 0.2));
 66      border-radius: 6px;
 67      box-shadow: var(--ae-popover-shadow, 0 5px 10px var(--ae-popover-border, rgba(0, 0, 0, 0.2)));
 68      display: flex;
 69  }
 70  .ae-popover-container {
 71      display: flex;
 72      flex-direction: column;
 73  }
 74  .ae-popover-title {
 75      padding: 8px 14px;
 76      margin: 0;
 77      font-size: 14px;
 78      background-color: var(--ae-popover-title-bg, #f7f7f7);
 79      border-bottom: 1px solid var(--ae-popover-title-border, #ebebeb);
 80      border-radius: 5px 5px 0 0;
 81  }
 82  .ae-popover-content {
 83      padding: 9px 14px;
 84      min-height: 0;
 85  }
 86  .ae-popover-container > .ae-arrow {
 87      border-width: 11px;
 88  }
 89  .ae-popover-container > .ae-arrow, .ae-popover-container > .ae-arrow:after {
 90      position: absolute;
 91      display: block;
 92      width: 0;
 93      height: 0;
 94      border-color: transparent;
 95      border-style: solid;
 96  }
 97  .ae-popover.left > .ae-popover-container > .ae-arrow {
 98      border-right-width: 0;
 99      border-left-color: var(--ae-popover-border, rgba(0, 0, 0, 0.2));
100  }
101  .ae-popover.right > .ae-popover-container > .ae-arrow {
102      border-right-color: var(--ae-popover-border, rgba(0, 0, 0, 0.2));
103      border-left-width: 0;
104  }
105  .ae-popover.bottom > .ae-popover-container > .ae-arrow {
106      border-top-width: 0;
107      border-bottom-color: var(--ae-popover-border, rgba(0, 0, 0, 0.2));
108  }
109  .ae-popover.top > .ae-popover-container > .ae-arrow {
110      border-top-color: var(--ae-popover-border, rgba(0, 0, 0, 0.2));
111      border-bottom-width: 0;
112  }
113  
114  .ae-popover > .ae-popover-container > .ae-arrow:after {
115      content: "";
116      border-width: 10px;
117  }
118  .ae-popover.right > .ae-popover-container > .ae-arrow:after {
119      bottom: -10px;
120      left: 1px;
121      content: " ";
122      border-right-color: var(--ae-popover-bg, #fff);
123      border-left-width: 0;
124  }
125  .ae-popover.left > .ae-popover-container > .ae-arrow:after {
126      right: 1px;
127      bottom: -10px;
128      content: " ";
129      border-right-width: 0;
130      border-left-color: var(--ae-popover-bg, #fff);
131  }
132  .ae-popover.top > .ae-popover-container > .ae-arrow:after {
133      bottom: 1px;
134      margin-left: -10px;
135      content: " ";
136      border-top-color: var(--ae-popover-bg, #fff);
137      border-bottom-width: 0;
138  }
139  .ae-popover.bottom >  .ae-popover-container > .ae-arrow:after {
140      top: 1px;
141      margin-left: -10px;
142      content: " ";
143      border-top-width: 0;
144      border-bottom-color: var(--ae-popover-bg, #fff);
145  }
146  """
147  
148  _html_injector.css(css)
149  
150  
151  class _State:
152      visible = "visible"
153      hidden = "hidden"
154  
155  
156  def _noop():
157      pass
158  
159  
160  def _get_popper_element(component):
161      if not isinstance(component, _anvil.Component):
162          raise TypeError(f"invalid component, got {type(component).__name__}")
163  
164      dom_node = _anvil.js.get_dom_node(component)
165      if isinstance(component, _anvil.Button):
166          return dom_node.firstElementChild
167  
168      return dom_node
169  
170  
171  def _is_on_screen(component):
172      open_form = _anvil.get_open_form()
173      if open_form is None:
174          return False
175  
176      parent = component
177      while parent is not None:
178          if parent is open_form:
179              return True
180          parent = parent.parent
181  
182      return False
183  
184  
185  def _get_root():
186      f = _anvil.get_open_form()
187      while isinstance(f, _anvil.WithLayout):
188          f = f.layout
189  
190      return f
191  
192  
193  _VALID_MAIN = ("top", "right", "bottom", "left", "bottom")
194  _VALID_SECONDARY = ("", "-start", "-end")
195  _VALID_PLACEMENTS = tuple(
196      f"{main}{secondary}" for main in _VALID_MAIN for secondary in _VALID_SECONDARY
197  )
198  _VALID_TRIGGERS = ("click", "hover", "focus", "stickyhover")
199  
200  
201  class Popover:
202      _id = 0
203      _has_sticky = 0
204  
205      @classmethod
206      def get_next_id(cls):
207          cls._id += 1
208          return cls._id
209  
210      def __init__(
211          self,
212          popper,
213          poppee,
214          title="",
215          placement="right",
216          trigger="click",
217          animation=True,
218          delay=None,
219          max_width=None,
220          auto_dismiss=True,
221          dismiss_on_scroll=None,
222          container=None,
223          arrow=True,
224      ):
225          _popper_map.set(popper, self)
226  
227          self.id = self.get_next_id()
228          self.state = _State.hidden
229  
230          self.popper = popper
231          self.poppee = poppee
232  
233          self.title = title
234          self.arrow = arrow
235  
236          if not isinstance(placement, str):
237              raise TypeError("placement must be a string")
238  
239          if placement in _VALID_PLACEMENTS:
240              self.placement = placement
241          else:
242              placements = placement.strip().lower().split(" ")
243              self.placement = next(
244                  (p for p in placements if p in _VALID_PLACEMENTS), "right"
245              )
246  
247          if not isinstance(trigger, str):
248              raise TypeError("trigger must be a string")
249  
250          if trigger in _VALID_TRIGGERS:
251              self.triggers = [trigger]
252          else:
253              self.triggers = trigger.strip().lower().split(" ")
254              if "manual" in self.triggers:
255                  self.triggers = ["manual"]
256  
257          self.animation_ms = 150 if animation else 0
258  
259          if isinstance(delay, (int, float)):
260              self.delay = {"show": delay, "hide": delay}
261          elif isinstance(delay, dict):
262              self.delay = {"show": 100, "hide": 100} | delay
263          elif delay is None:
264              self.delay = {"show": 100, "hide": 100}
265          else:
266              raise TypeError("delay must be an int, float, dict or None")
267  
268          self.max_width = _default_max_width if max_width is None else max_width
269          self.container = _default_container if container is None else container
270  
271          self.auto_dismiss = auto_dismiss
272  
273          self.timeouts = []
274          self.cleanup = _noop
275  
276          # we use this to allow show-hide events to be fired on the content
277          self.fake_container = _anvil.Container()
278          self._clicked = False
279  
280          if dismiss_on_scroll is not None:
281              _warn(
282                  "popover.dismiss_on_scroll",
283                  "dismiss_on_scroll option is deprecated",
284                  "DEPRECATION WARNING",
285              )
286  
287          self.make_template()
288          self.add_behavior()
289  
290      def make_template(self):
291          d = _document.createElement("div")
292          d.className = "ae-popover"
293          d.style.position = "absolute"
294          d.style.visibility = "hidden"
295          d.style.opacity = "0"
296          d.style.maxWidth = self.max_width
297          ms = self.animation_ms
298          if ms:
299              d.style.transition = f"opacity {ms}ms linear, visibility {ms}ms linear"
300          self.init_popover(d)
301          d.role = "tooltip"
302  
303          c = _document.createElement("div")
304          c.className = "ae-popover-container"
305          d.append(c)
306  
307          arrow = _document.createElement("div")
308          if self.arrow:
309              c.append(arrow)
310              arrow.className = "ae-arrow"
311  
312          title = _document.createElement("div")
313          c.append(title)
314          if self.title:
315              title.textContent = self.title
316          else:
317              title.style.display = "none"
318          title.className = "ae-popover-title"
319  
320          content = _document.createElement("div")
321          c.append(content)
322          content.className = "ae-popover-content"
323  
324          self.dom_popover = d
325          self.dom_content = content
326          self.dom_arrow = arrow
327  
328      def init_popover(self, element):
329          element = _anvil.js.get_dom_node(element)
330          element.setAttribute("ae-popover", "")
331          element.setAttribute("ae-popover-id", self.id)
332  
333      def cleanup_popover(self, element):
334          try:
335              element = _anvil.js.get_dom_node(element)
336          except TypeError:
337              pass
338          element.removeAttribute("ae-popover")
339          element.removeAttribute("ae-popover-id")
340  
341      def add_behavior(self):
342          self.popper.add_event_handler("x-anvil-page-shown", self.handle_mount)
343          self.popper.add_event_handler("x-anvil-page-hidden", self.handle_cleanup)
344          if _is_on_screen(self.popper):
345              self.handle_mount()
346  
347      def handle_mount(self, **e):
348          el = _get_popper_element(self.popper)
349          if "click" in self.triggers:
350              el.addEventListener("click", self.toggle, True)
351          if "hover" in self.triggers:
352              el.addEventListener("mouseenter", self.show, True)
353              el.addEventListener("mouseleave", self.hide, True)
354          if "focus" in self.triggers:
355              el.addEventListener("focus", self.show, True)
356              el.addEventListener("blur", self.hide, True)
357          if "stickyhover" in self.triggers:
358              if not Popover._has_sticky:
359                  _document.body.addEventListener(
360                      "mouseleave", self.document_sticky_mouseleave_handler, True
361                  )
362  
363              Popover._has_sticky += 1
364              el.addEventListener("mouseenter", self.show, True)
365              el.addEventListener("mouseleave", self.sticky_hide, True)
366  
367      def handle_cleanup(self, **e):
368          el = _get_popper_element(self.popper)
369          if "click" in self.triggers:
370              el.removeEventListener("click", self.toggle, True)
371          if "hover" in self.triggers:
372              el.removeEventListener("mouseenter", self.show, True)
373              el.removeEventListener("mouseleave", self.hide, True)
374          if "focus" in self.triggers:
375              el.removeEventListener("focus", self.show, True)
376              el.removeEventListener("blur", self.hide, True)
377          if "stickyhover" in self.triggers:
378              Popover._has_sticky -= 1
379              if not Popover._has_sticky:
380                  _document.body.removeEventListener(
381                      "mouseleave", self.document_sticky_mouseleave_handler, True
382                  )
383  
384              el.removeEventListener("mouseenter", self.show, True)
385              el.removeEventListener("mouseleave", self.sticky_hide, True)
386  
387      @staticmethod
388      def document_sticky_mouseleave_handler(e):
389          # did we leave a popover?
390          target = _clean_target(e.target)
391          if not (target and target.hasAttribute("ae-popover")):
392              return
393  
394          popover_id = int(target.getAttribute("ae-popover-id"))
395  
396          # are we still hovering over the same popover?
397          if _document.querySelector(f"[ae-popover-id='{popover_id}']:hover"):
398              return
399  
400          popper = _visible_popovers.get(popover_id)
401          if popper is None:
402              return
403  
404          popover = _popper_map.get(popper)
405          if popover is not None:
406              popover.sticky_hide(e)
407  
408      def sticky_hide(self, *e):
409          from time import sleep
410  
411          sleep(0.1)  # small delay to allow the mouse to move to the element
412          if not _document.querySelector(f"[ae-popover-id='{self.id}']:hover"):
413              self.hide(*e)
414  
415      def clear_timeouts(self):
416          for timeout in self.timeouts:
417              _W.clearTimeout(timeout)
418          self.timeouts = []
419  
420      def animate(self, show=True):
421          self.dom_popover.style.visibility = "visible" if show else "hidden"
422          self.dom_popover.style.opacity = 1 if show else 0
423  
424      def animate_in(self):
425          self.animate(True)
426  
427      def animate_out(self):
428          self.animate(False)
429  
430      def setup_dom(self):
431          if self.fake_container.parent is None:
432              root = _get_root()
433              if root is not None:
434                  root.add_component(self.fake_container)
435  
436          if self.poppee.parent is None:
437              self.fake_container.add_component(self.poppee)
438  
439          if self.dom_content.firstChild is None:
440              self.dom_content.append(_anvil.js.get_dom_node(self.poppee))
441  
442          el = _get_popper_element(self.popper)
443          self.init_popover(el)
444  
445          for c in _walk(self.poppee):
446              c.raise_event("x-popover-init", init_node=self.init_popover)
447  
448          if self.dom_popover.isConnected:
449              return
450  
451          container = self.container
452          if container == "body":
453              container = _document.body
454          elif isinstance(container, str):
455              container = _document.querySelector(container)
456              if container is None:
457                  container = _document.body
458          try:
459              container.append(self.dom_popover)
460          except AttributeError:
461              _document.body.append(self.dom_popover)
462  
463      def cleanup_dom(self):
464          self.dom_popover.remove()
465          self.poppee.remove_from_parent()
466          self.fake_container.remove_from_parent()
467          el = _get_popper_element(self.popper)
468          self.cleanup_popover(el)
469  
470          for c in _walk(self.poppee):
471              c.raise_event("x-popover-destroy", init_node=self.cleanup_popover)
472  
473      def on_shown(self):
474          pass
475  
476      def show(self, *e):
477          # exit early if we're already showing
478          is_hover = e and e[0].type == "mouseenter"
479          if not is_hover:
480              self._clicked = True
481  
482          if self.state == _State.visible:
483              return
484  
485          self.state = _State.visible
486          _visible_popovers[self.id] = self.popper
487  
488          self.cleanup()
489          self.setup_dom()
490  
491          self.clear_timeouts()
492  
493          self.dom_popover.style.display = ""
494  
495          self.cleanup = fui.auto_update(
496              _get_popper_element(self.popper),
497              self.dom_popover,
498              placement=self.placement,
499              arrow=self.dom_arrow if self.arrow else None,
500          )
501  
502          delay = self.delay["show"] if e else 0
503          self.timeouts.append(_W.setTimeout(self.animate_in, delay))
504          self.timeouts.append(_W.setTimeout(self.on_shown, delay + self.animation_ms))
505          self.poppee.raise_event("x-popover-show")
506  
507      def on_hidden(self):
508          self.dom_popover.style.display = "none"
509          self.cleanup()
510          self.cleanup = _noop
511          self.cleanup_dom()
512  
513      def hide(self, *e):
514          is_hover = e and e[0].type == "mouseleave"
515  
516          if not is_hover:
517              self._clicked = False
518  
519          if self.state == _State.hidden:
520              return
521  
522          if is_hover and self._clicked:
523              return
524  
525          self.state = _State.hidden
526          _visible_popovers.pop(self.id, None)
527  
528          self.clear_timeouts()
529  
530          delay = self.delay["hide"] if e else 0
531          self.timeouts.append(_W.setTimeout(self.animate_out, delay))
532          self.timeouts.append(_W.setTimeout(self.on_hidden, delay + self.animation_ms))
533          self.poppee.raise_event("x-popover-hide")
534  
535      def shown(self):
536          return self.state == _State.visible
537  
538      def toggle(self, *e):
539          if self.state == _State.visible:
540              self.hide(*e)
541          else:
542              self.show(*e)
543  
544      def destroy(self):
545          # remove all event listeners
546          self.clear_timeouts()
547          try:
548              self.popper.remove_event_handler("x-anvil-page-shown", self.handle_mount)
549              self.popper.remove_event_handler("x-anvil-page-hidden", self.handle_cleanup)
550          except Exception:
551              pass
552  
553          if _is_on_screen(self.popper):
554              self.handle_cleanup()
555  
556          # remove us from the popper map
557          _popper_map.delete(self.popper)
558          _visible_popovers.pop(self.id, None)
559          self.on_hidden()
560  
561      def is_visible(self):
562          return self.shown()
563  
564      def update(self):
565          # no longer does anything since we are using autoUpdate
566          pass
567  
568  
569  def popover(
570      self,
571      content,
572      title="",
573      placement="right",
574      trigger="click",
575      animation=True,
576      delay={"show": 100, "hide": 100},
577      max_width=None,
578      auto_dismiss=True,
579      dismiss_on_scroll=None,
580      container=None,
581      arrow=True,
582  ):
583      """should be called by a button or link
584      content - either text or an anvil component or Form
585      placement -  right, left, top, bottom, auto (for left/right best to have links and buttons inside flow panels)
586      trigger - manual, focus, hover, click (can be a combination of two e.g. 'hover focus')
587      animation - True or False
588      delay - {'show': 100, 'hide': 100}
589      max_width - bootstrap default is 276px you might want this wider
590  
591      if the content is a form then the form will have an attribute self.popper added
592      """
593      if _popper_map.has(self):
594          _warn(
595              "popover.has_pop",
596              "attempted to create a popover on a component that already has one. This will have no effect.\n"
597              "Destroy the popover before creating a new one using component.pop('destroy').\n"
598              "Or, use has_popover() to check if this component aleady has a popover before creating a new one.",
599          )
600          return
601  
602      if isinstance(content, str):
603          content = _anvil.Label(text=content)
604      if isinstance(content, _anvil.Component):
605          try:
606              content.popper = self  # add the popper to the content form
607          except AttributeError:
608              pass
609      else:
610          raise TypeError(
611              f"content to a popover should be either a str or anvil Component, not {type(content).__name__}"
612          )
613  
614      parent = content.parent
615      if parent is not None and type(parent) is not _anvil.Container:
616          _warn(
617              "popover.has_parent",
618              "the popover content already has a parent this can cause unusual behaviour.\n"
619              "Support for this may be removed in a future version.",
620          )
621  
622      Popover(
623          self,
624          content,
625          title=title,
626          placement=placement,
627          trigger=trigger,
628          animation=animation,
629          delay=delay,
630          max_width=max_width,
631          auto_dismiss=auto_dismiss,
632          dismiss_on_scroll=dismiss_on_scroll,
633          container=container,
634          arrow=arrow,
635      )
636  
637  
638  def pop(self, behavior):
639      """behaviour can be any of
640      show, hide, toggle, destroy (included with bootstrap 3.4.1)
641  
642      features added not in bootstrap 3.4.1 docs:
643      update  - updates position of popover - useful for dynamic content that changes the size of the popover
644      shown: returns True or False if the popover is visible - note a popover will only be visible after it has animated onto screen so may need to sleep(.15) before calling
645      is_visible: same as shown
646      """
647      popover = _popper_map.get(self)
648      if not popover:
649          return
650  
651      execute = getattr(popover, behavior, _noop)
652      return execute()
653  
654  
655  def get_all_parent_popover_ids(target):
656      parent_ids = []
657      current_element = target
658  
659      while current_element and current_element.tagName.lower() != "body":
660          if current_element.hasAttribute("ae-popover-id"):
661              try:
662                  popover_id = int(current_element.getAttribute("ae-popover-id"))
663                  parent_ids.append(popover_id)
664              except (ValueError, TypeError):
665                  # Skip if the attribute is not a valid integer
666                  pass
667          current_element = current_element.parentElement
668  
669      return parent_ids
670  
671  
672  def _clean_target(target):
673      """ensure we are dealing with a dom element and not a node"""
674      if not target:
675          return None
676      if target.nodeType != 1:
677          target = target.parentElement
678      return target
679  
680  
681  def _hide_popovers_on_outside_click(e):
682      target = _clean_target(e.target)
683      parent_ids = get_all_parent_popover_ids(target)
684  
685      # Use a copy since the dict changes size during iteration
686      for popover_id, popper in _visible_popovers.copy().items():
687          if popover_id in parent_ids:
688              # Skip hiding popovers that are parents of the clicked element
689              continue
690  
691          popover = _popper_map.get(popper)
692          if not popover:
693              continue
694  
695          if popover.auto_dismiss:
696              popover.hide()
697  
698  
699  # this is the default behavior
700  def dismiss_on_outside_click(dismiss=True):
701      """hide popovers when a user clicks outside the popover
702      this is the default behavior
703      """
704      _document.body.removeEventListener("click", _hide_popovers_on_outside_click, True)
705      if dismiss:
706          _document.body.addEventListener("click", _hide_popovers_on_outside_click, True)
707  
708  
709  @_deprecated("dismiss_on_scroll is deprecated")
710  def dismiss_on_scroll(dismiss=True):
711      """Deprecated."""
712      pass
713  
714  
715  _default_max_width = ""
716  
717  
718  def set_default_max_width(width):
719      """update the default max width - this is 276px by default - useful for wider components"""
720      global _default_width
721      _default_width = width
722  
723  
724  _default_container = "body"
725  
726  
727  def set_default_container(selector_or_element):
728      """The default container for popovers is the body page.
729      In advanced set ups when the popovers can scroll with the element, you will want to change this.
730      This can also be set per popover"""
731      global _default_container
732      _default_container = selector_or_element
733  
734  
735  def has_popover(self):
736      return _popper_map.has(self)
737  
738  
739  _anvil.Component.popover = popover
740  _anvil.Component.pop = pop
741  
742  
743  # make this the default behaviour
744  dismiss_on_outside_click(True)