/ 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)