Blog-Archiv

Sonntag, 12. Juli 2015

JS Animated Expand and Collapse

Text folding is nice, but nowadays anything has to be animated. This is a matter of ergonomics. Because animations are mostly done using CSS, I tried to find a pure CSS solution for this. You can follow them trying on Stackoverflow. None of these solutions manage to come across the fact that the CSS transition needs a hard-coded height value. Now do this for a paragraph whose content changes every week, and for devices of all size!

If you want to hack on it, here is some HTML code (for copy/paste) that demonstrates that inability.

Click to see source (animated:-).
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
  
    <title>Pure CSS Height Transition</title>
    
    <style type="text/css">
      button:active + p {
        height: auto;  /* ... "auto" is not animated, would need hardcoded height! */
      }
      p {
        height: 0;
        overflow: hidden;
        transition: 1s;
      }
    </style>
  </head>
  
  <body>
    
    <button>Click to Expand Text</button>
    
    <p>
      Put some very long text to here ...
    </p>
      
  </body>
</html>

Before trying this out you should put some Lorem Ipsum (long text) into the p element, else the animation will be hard to see.

The best workaround I found was one that recommended to use max-height and not height, and put an astronomic value to max-height in expanded state. Result is that the animation is also "astronomic" :-)

My advice for CSS ambitions is:

Do not go for a pure CSS solution when it does not work after a short time of trying.
This Blog is about an animated expand/collapse solution using JavaScript.


Specification

My user stories for this are:

As a browser user, ....

  • ... I do not want to initially see unimportant things on a web page
  • ... I want to see the unimportant things by clicking on some expansion-arrow
  • ... I want the expansion to happen ergonomically, i.e. the transition should be slow, at about 1 second

Another kind of user stories are programmer stories. Because any solution needs programmers to be integrated into an application, the API of the solution needs to be simple but precise, and safe against abuse. This is a matter of software survival!

As a web programmer, ....

  • ... I want to write as less code as possible into the web page to achieve animated expand/collapse
  • ... I do not want to reference lots of different resources (arrow images, CSS files, JS files, ...)
  • ... it should not be necessary to implement a complicated initialization to make this working

API Design

Here is the HTML shape I want to achieve.

    <div toggle-id="one">Reveal Paragraph One</div>
    <p id="one">Some long long text here ...</p>

    <p toggle-id="two">Reveal Division Two</p>
    <div id="two">Some long long text here ...</div>

It should be possible to use any element type as expansion trigger, and it should be possible to use any type of element for the expansion target. In the example above, the first expansion trigger is a div and the target is a p, while in the second example it is the other way round.

The expansion trigger declares its target in an attribute called toggle-id, while the target wears the value referenced there in its id attribute. Nothing more is needed, anything else (arrows) can be done by the script. So that's the API to satisfy the programmer stories.

JS Implementation

The script should be a reusable module, concentrating on what's to do and nothing else. I will introduce it piece by piece, and then provide the whole source on bottom of the page.

Initialization

Following is the start of a factory-function that will be continued by snippets across following chapters.

      var expanderFactory = function(initiallyExpanded)
      {
        var that = {};
        var toggleSelector = "*[toggle-id]";
        
        that.install = function() {
          var toggles = document.querySelectorAll(toggleSelector);
          for (var i = 0; i < toggles.length; i++)
            initialize(toggles[i]);
          
          var style = document.createElement("style");
          document.head.appendChild(style);
          var sheet = style.sheet;
          sheet.insertRule(toggleSelector+".collapsed::before { content: '\u25B6 '; }", 0);
          sheet.insertRule(toggleSelector+".expanded::before { content: '\u25BC '; }", 1);
        };

        var initialize = function(toggle) {
          toggle.style.cursor = "pointer";
          toggle.className += initiallyExpanded ? " expanded" : " collapsed";
          toggle.addEventListener("click", function() {
            doToggle(toggle);
          });
          
          var target = getToggleTarget(toggle);
          target.myOriginalHeight = target.clientHeight;
          target.style.height = initiallyExpanded ? target.myOriginalHeight+"px" : "0";
          target.style.overflow = "hidden";
          target.style.transition = "height 0.5s";
        };
 

This code provides a public install() function that should be called on page-load. It searches for elements wearing the toggle-id attribute and initializes them.

After that it creates a CSS style element in the HTML head, where it declares two rules. One is a pseudo-element for the CSS class expanded, the other a pseudo-element for the CSS class collapsed. These are the expansion arrows.
I could also have done this by DOM manipulations, but as it is only a few lines of code, I did it via CSS.

The private initialize() function defines the hand-cursor upon the toggle-element (expansion trigger). It then sets the according CSS class on it to generate the expand- or collapse-arrow via pseudo-element. Then it installs a click-listener that calls the doToggle() function when the user clicks on it.

Next it does some size management. This works only when the page already has been rendered. It retrieves the target element of the toggle, and stores the original height of the element (target.clientHeight) into a new attribute called myOriginalHeight. (In JavaScript, unlike in Java, it is possible at any time and on every object to create a new attribute.) This will be the height used to expand the element. It then sets overflow: hidden, else the element would show despite of height 0. Finally it declares the animation via a CSS transition of 1 second on property height.

Event Callback

        var getToggleTarget = function(toggle) {
          var targetId = toggle.getAttribute("toggle-id");
          return document.getElementById(targetId);
        };
        
        var doToggle = function(toggle) {
          var wasExpanded = /\bexpanded\b/.test(toggle.className);
          var target = getToggleTarget(toggle);
          target.style.height = wasExpanded ? "0" : target.myOriginalHeight+"px";
          toggle.className = toggle.className.replace(
              wasExpanded ? /\bexpanded\b/ : /\bcollapsed\b/,
              wasExpanded ? "collapsed" : "expanded");
        };

This is the event handling when the user clicks on the expansion trigger (toggle). The getToggleTarget() function fetches the target element by calling the document.getElementById() function, using the value of the toggle-id attribute.

The doToggle() function then does the work. First it finds out whether the eleent is currently expanded or collapsed by searching for the according CSS class. It then toggles the height of the arget element to either its original or zero height. Then it replaces the CSS class by its counterpart, to store the expansion state of the element.

Instantiation, Installation

        
        return that;
        
      };
      
      var expander = expanderFactory(true); /* initiallyExpanded */
      window.addEventListener("load", expander.install);
 

This is the end of the factory function, and the concrete instatiation of an expander-object from it. Passing a value of true would cause all text to be expanded.

This code then registers the expander's install() function to be called on the browser's on-load event, when the page will be already rendered and all heights are calculated. These two lines are meant to be in a <script> tag anywhere in the HTML page when the factory-function is loaded as module from a JS-file.

Here you can see the script in action (click on this text, or the left-side arrow). It reveals its own source code, wrapped in an example HTML. This source also contains functions to perform a bulk toggle of all expansion-elements in the page.

 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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
<!DOCTYPE HTML>
<html>
  <head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1"/>
  
    <title>JS animated expand/collapse</title>
  </head>
  
  <body>
    <div toggle-id="one">Reveal One</div> 
    <p id="one">I am One<br>I am One<br>I am One<br>I am One<br>I am One<br></p>
    
    <p toggle-id="two">Reveal Two</p> 
    <div id="two">I am Two<br>I am Two<br>I am Two<br>I am Two<br>I am Two<br>I am Two<br></div>
    
    
    <script type="text/javascript">
      var expanderFactory = function(initiallyExpanded)
      {
        var that = {};
        var toggleSelector = "*[toggle-id]";
        
        var getToggleTarget = function(toggle) {
          var targetId = toggle.getAttribute("toggle-id");
          return document.getElementById(targetId);
        };
        
        var doToggle = function(toggle) {
          var wasExpanded = /\bexpanded\b/.test(toggle.className);
          var target = getToggleTarget(toggle);
          target.style.height = wasExpanded ? "0" : target.myOriginalHeight+"px";
          toggle.className = toggle.className.replace(
              wasExpanded ? /\bexpanded\b/ : /\bcollapsed\b/,
              wasExpanded ? "collapsed" : "expanded");
        };
        
        var toggleAll = function(expand) {
          var toggles = document.querySelectorAll(toggleSelector);
          for (var i = 0; i < toggles.length; i++)
            if (expand != /\bexpanded\b/.test(toggles[i].className))
              doToggle(toggles[i]);
        };
        
        var initialize = function(toggle) {
          toggle.style.cursor = "pointer";
          toggle.className += initiallyExpanded ? " expanded" : " collapsed";
          toggle.addEventListener("click", function() {
            doToggle(toggle);
          });
          
          var target = getToggleTarget(toggle);
          target.myOriginalHeight = target.clientHeight;
          target.style.height = initiallyExpanded ? target.myOriginalHeight+"px" : "0";
          target.style.overflow = "hidden";
          target.style.transition = "height 0.5s";
        };
        
        that.install = function() {
          var toggles = document.querySelectorAll(toggleSelector);
          for (var i = 0; i < toggles.length; i++)
            initialize(toggles[i]);
          
          var style = document.createElement("style");
          document.head.appendChild(style);
          var sheet = style.sheet;
          sheet.insertRule(toggleSelector+".collapsed::before { content: '\u25B6 '; }", 0);
          sheet.insertRule(toggleSelector+".expanded::before { content: '\u25BC '; }", 1);
        };
        
        that.expandAll = function() {
          toggleAll(true);
        };
        
        that.collapseAll = function() {
          toggleAll(false);
        };
        
        return that;
        
      };
      
      var expander = expanderFactory(true); /* initiallyExpanded */
      window.addEventListener("load", expander.install);
      
    </script>
    
  </body>
</html>

Available also on my homepage.





Keine Kommentare: