Blog-Archiv

Samstag, 7. Oktober 2017

JS Popup Window

Popup menus, nowadays called context menus, were always some kind of challenge, on any platform. A lot of bugs occurred with them, either because the API was unclear, or because they did not work properly. I remember that the AWT PopupMenu of an early LINUX Java could freeze the whole application.

Popup menu sister is the dropdown list, a further relative is the combo box. (Also the Java/Swing JComboBox has an eventful history.) All these show a clickable window that temporarily hovers above the user interface.

Why is that window so problematic? Because it is not contained in the component-tree of the user interface, it is not in the layout, it is invisible and displays just on a certain user gesture, usually a right mouse click, or Shift-F10 key. Then it needs to gain some position where the user really can see and use it, which is more difficult than expected, especially in multi-screen environments. Finally it must be removed by events that may not have been sent to the component that contained it. (Imagine an open context menu, and then you focus another application on your desktop. Would you expect it to still be open when you come back?)


This Blog is about how to manage a popup window in a web page. I will present JavaScript functions that build, open and close it safely. You can find this also on my homepage. There are simpler solutions for popup / dropdown windows, but they do not work always and under all circumstances.

Example

Scroll to any "Popup X" and click it.
For simplicity, this popup shows on left mouse click, other than traditional context menus that show on right mouse click.


1
2
3
4
5
6
7
8
9
10
1
2
3
4
5
6
7
8
9
10

Event Log:

Tests:

  1. Click a "Popup X".
    Expected: a white popup window opens.

  2. Click into the open popup window.
    Expected: the click target is reported in event log.

  3. Open a "Popup". Click anywhere but not in a "Popup" or a scrollbar.
    Expected: the open popup closes.

  4. Open a "Popup". Open another "Popup".
    Expected: first popup closes when second opens.

  5. Make browser page small. Open a "Popup" outside the blue scroll-pane. Scroll the browser page.
    Expected: the open popup closes.

  6. Open a "Popup" inside the blue scroll-pane. Scroll the pane.
    Expected: the open popup closes.

HTML and CSS

In this example, the popup is contained within its visual representation (the JS source below postulates that). The popup is tagged with CSS class popup. The visual representation renders just the popup's first text line. To do that, it was set to a fixed height, with CSS overflow hidden.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<!DOCTYPE HTML>
<html>
  <head>
    <meta charset="UTF-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
    <title>JS Popup Context</title>

    <style>
      .popup-container
      {
        height: 1.2em; /* view just first line of nested popup */
        overflow: hidden; /* hides lower part of nested popup */
        
        /* following are not needed */
        width: 8em; /* arbitrary width */
        border: 1px solid gray; /* visual marker */
      }
      .popup
      {
        min-width: 8em; /* minimal width is same as container */
      }
    </style>

  </head>
  
  <body>

    <div class="popup-container">
      <div class="popup">
        <div>Popup 0</div>
        <div>Menu</div>
        <div>Items</div>
      </div>
    </div>

    <script>
      (function() {
        /* JS source goes here */
      })();
    </script>

  </body>

</html>

JS Source Code

The popup principles are following:

  1. there must be a visual representation of the popup, and a hidden list of items or whatever

  2. the popup needs to be cloned, because the original element might not display correctly in its parent's scope; the clone needs to be added to the document's body, hidden, and its CSS position must be absolute

  3. clicking onto the visual representation makes the hidden clone visible, and positions it to where the visual representation is; the JS function getBoundingClientRect() lets calculate this

  4. a click onto the popup will close it

  5. any click outside the popup must also close it

  6. any scrolling also should close the popup; if this is not wanted, the popup must move with the scrolling container

Put following functions to where /* JS source goes here */ is.

Script Initialization

This loops all popups and installs them. A click callback is added on each one, logging the event target. (In real world, you would replace this by some context menu event dispatcher.)

This source code needs to be on bottom of the script element, all other functions must be somewhere above it.

      var popups = document.querySelectorAll(".popup");
      for (var i = 0; i < popups.length; i++) {
        var popupClone = installPopup(popups[i]);
        
        popupClone.addEventListener("click", function(event) {
          var log = document.getElementById("log");
          var clickTarget = (event.target || event.srcElement);
          log.innerHTML = log.innerHTML+"<p>Click target was: '"+clickTarget.textContent+"'</p>";
        });
      }

Installation

This prepares the given popup for usage. It adds all necessary listeners to open and close it.

      var installPopup = function(popup, keepOpenOnScroll) {
        var popupClone = createPopupClone(popup);
        
        /* event listener that opens the popup */
        popup.addEventListener("click", function(event) {
          openPopup(popup, popupClone, event);
        });
        
        /* event listeners that close the popup */
        var close = function() {
          closePopup(popupClone);
        };
        popupClone.addEventListener("blur", close);
        popupClone.addEventListener("click", close);
        
        if ( ! keepOpenOnScroll ) {
          var scrollParent = findScrollParent(popup);
          if (scrollParent)
            scrollParent.addEventListener("scroll", close);
          
          window.addEventListener("scroll", close);
        }
        
        return popupClone;
      };

Create Invisible Popup as Clone

This clones the popup element and adds it invisibly to the document's body root.

      var createPopupClone = function(popup) {
        var popupClone = popup.cloneNode(true); /* create a deep clone */
        
        closePopup(popupClone); /* set it invisible */
        
        if ( ! popupClone.style["background-color"] )
          popupClone.style["background-color"] = "white"; /* don't be transparent */
          
        popupClone.style["position"] = "absolute"; /* position will be calculated */
        document.body.appendChild(popupClone); /* add to root level for absolute positioning */
        
        popupClone.setAttribute("tabindex", 0); /* make it focusable for blur-listener */
        
        return popupClone;
      };

Open and Close the Popup

The open-function calculates the absolute coordinates of the popup and positions it there. (This could be refined to calculate an actually visible rectangle on the screen when being on bottom. See the location() and smartCoordinate() functions in my tooltip Blog for improving this.)

The focus() call sets the input focus onto the popup clone. This is necessary for the blur listener to work. Setting the clone's tabindex attribute to 0 made it focusable (see createPopupClone() above).

      var openPopup = function(popup, popupClone, clickEvent) {
        var rectangle = popup.getBoundingClientRect();
        var scrollOffsetLeft = window.pageXOffset || document.documentElement.scrollLeft;
        var scrollOffsetTop  = window.pageYOffset || document.documentElement.scrollTop;
        popupClone.style.top = Math.round(rectangle.top + scrollOffsetTop)+"px";
        popupClone.style.left = Math.round(rectangle.left + scrollOffsetLeft)+"px";
        
        popupClone.style["display"] = "";
        popupClone.focus();
      };
        
      var closePopup = function(popupClone) {
        popupClone.style["display"] = "none";
      };

Find Parent Scroll Containers

These functions identify and find parent elements that show either an horizontal or a vertical scrollbar. A listener for these would use the "scroll" event type.

Mind that, in nested scroll panes, when you want to not close the popup on scrolling, the popup would not scroll with the parent. It would stay on its absolute coordinate. In that case you need to implement some kind of re-positioning on each scroll event.

      var isScrollPane = function(element) {
        var scrolls = (element.offsetHeight < element.scrollHeight || element.offsetWidth < element.scrollWidth);
        if ( ! scrolls )
          return false;
          
        /* H1 elements cause this to fail when not checking "overflow" */
        var style = window.getComputedStyle(element);
        return style.overflow === "scroll" || style.overflow === "auto";
      };
    
      var findScrollParent = function(element) {
        while (element.parentElement && element.parentElement !== document.body) {
          if ( isScrollPane(element.parentElement) )
            return element.parentElement;
            
          element = element.parentElement;
        }
        return undefined;
      };

Resume

Popups on web pages can be managed in many different ways. I tried to show a minimalistic and safe one, pointing out just the necessities.




Keine Kommentare: