Tree View

Tree View

A tree view is a hierarchical structure with parent and child nodes that can expand and collapse. Tree views on the web are not common, but they do exist, often to represent a file system or other similar structure of folders and files. Sometimes navigation menus are represented in a tree view type of way. A true ARIA tree view is navigable with the arrow keys on the keyboard instead of the tab key, which is an expected keyboard for native OS applications, but less expected (at least for now) on the web. Support for ARIA tree views is good in some screen readers, but poor in others, so the choice to implement an ARIA tree view should not be made lightly, and may require that you create alternative methods to access the same functionality.



Turn on a screen reader to experience this example in action.

  • School Activities
    • Performance
      • Band
      • Choir
      • Theater
    • Sports
      • Fall
        • Football
        • Soccer
        • Cross Country
        • Cheerleading
      • Winter
        • Basketball
        • Wrestling
        • Cheerleading
      • Spring
        • Baseball
        • Track
  • School Events
    • For Students
      • Dances
        • Spring Formal
        • Winter Formal
      • Assemblies
        • Homecoming
        • Multicultural Assembly
    • For Parents
      • Parent-Teacher Conference

Initial HTML Markup

<div id="deque-tree-no-select" class="deque-tree">
  <div id="deque-tree-root">
    <ul role="tree">
      <li role="treeitem" aria-label="School Activities" data-tree-root="deque-tree-root">
        <div class="deque-tree-branch"></div>
        <span class="deque-tree-label">School Activities</span>
        <ul role="group">
          <li role="treeitem" aria-label="Performance" data-tree-root="deque-tree-root">
            <div class="deque-tree-branch"></div>
            <span class="deque-tree-label">Performance</span>
            <ul role="group">
              <li role="treeitem" aria-label="Band" data-tree-root="deque-tree-root">
                <div class="deque-tree-leaf"></div>
                <span class="deque-tree-label">Band</span>
              </li>
              <li role="treeitem" aria-label="Choir" data-tree-root="deque-tree-root">
                <div class="deque-tree-leaf"></div>
                <span class="deque-tree-label">Choir</span>
              </li>
              <li role="treeitem" aria-label="Theater" data-tree-root="deque-tree-root">
                <div class="deque-tree-leaf"></div>
                <span class="deque-tree-label">Theater</span>
              </li>
            </ul>
          </li>
          <li role="treeitem" aria-label="Sports" data-tree-root="deque-tree-root">
            <div class="deque-tree-branch"></div>
            <span class="deque-tree-label">Sports</span>
            <ul role="group">
              <li role="treeitem" aria-label="Fall" data-tree-root="deque-tree-root">
                <div class="deque-tree-branch"></div>
                <span class="deque-tree-label">Fall</span>
                <ul role="group">
                  <li role="treeitem" aria-label="Football" data-tree-root="deque-tree-root">
                    <div class="deque-tree-leaf"></div>
                    <span class="deque-tree-label">Football</span>
                  </li>
                  <li role="treeitem" aria-label="Soccer" data-tree-root="deque-tree-root">
                    <div class="deque-tree-leaf"></div>
                    <span class="deque-tree-label">Soccer</span>
                  </li>
                  <li role="treeitem" aria-label="Cross Country" data-tree-root="deque-tree-root">
                    <div class="deque-tree-leaf"></div>
                    <span class="deque-tree-label">Cross Country</span>
                  </li>
                  <li role="treeitem" aria-label="Cheerleading" data-tree-root="deque-tree-root">
                    <div class="deque-tree-leaf"></div>
                    <span class="deque-tree-label">Cheerleading</span>
                  </li>
                </ul>
              </li>
              <li role="treeitem" aria-label="Winter" data-tree-root="deque-tree-root">
                <div class="deque-tree-branch"></div>
                <span class="deque-tree-label">Winter</span>
                <ul role="group">
                  <li role="treeitem" aria-label="Basketball" data-tree-root="deque-tree-root">
                    <div class="deque-tree-leaf"></div>
                    <span class="deque-tree-label">Basketball</span>
                  </li>
                  <li role="treeitem" aria-label="Wrestling" data-tree-root="deque-tree-root">
                    <div class="deque-tree-leaf"></div>
                    <span class="deque-tree-label">Wrestling</span>
                  </li>
                  <li role="treeitem" aria-label="Cheerleading" data-tree-root="deque-tree-root">
                    <div class="deque-tree-leaf"></div>
                    <span class="deque-tree-label">Cheerleading</span>
                  </li>
                </ul>
              </li>
              <li role="treeitem" aria-label="Spring" data-tree-root="deque-tree-root">
                <div class="deque-tree-branch"></div>
                <span class="deque-tree-label">Spring</span>
                <ul role="group">
                  <li role="treeitem" aria-label="Baseball" data-tree-root="deque-tree-root">
                    <div class="deque-tree-leaf"></div>
                    <span class="deque-tree-label">Baseball</span>
                  </li>
                  <li role="treeitem" aria-label="Track" data-tree-root="deque-tree-root">
                    <div class="deque-tree-leaf"></div>
                    <span class="deque-tree-label">Track</span>
                  </li>
                </ul>
              </li>
            </ul>
          </li>
        </ul>
      </li>
      <li role="treeitem" aria-label="School Events" data-tree-root="deque-tree-root">
        <div class="deque-tree-branch"></div>
        <span class="deque-tree-label">School Events</span>
        <ul role="group">
          <li role="treeitem" aria-label="For Students" data-tree-root="deque-tree-root">
            <div class="deque-tree-branch"></div>
            <span class="deque-tree-label">For Students</span>
            <ul role="group">
              <li role="treeitem" aria-label="Dances" data-tree-root="deque-tree-root">
                <div class="deque-tree-branch"></div>
                <span class="deque-tree-label">Dances</span>
                <ul role="group">
                  <li role="treeitem" aria-label="Spring Formal" data-tree-root="deque-tree-root">
                    <div class="deque-tree-leaf"></div>
                    <span class="deque-tree-label">Spring Formal</span>
                  </li>
                  <li role="treeitem" aria-label="Winter Formal" data-tree-root="deque-tree-root">
                    <div class="deque-tree-leaf"></div>
                    <span class="deque-tree-label">Winter Formal</span>
                  </li>
                </ul>
              </li>
              <li role="treeitem" aria-label="Assemblies" data-tree-root="deque-tree-root">
                <div class="deque-tree-branch"></div>
                <span class="deque-tree-label">Assemblies</span>
                <ul role="group">
                  <li role="treeitem" aria-label="Homecoming" data-tree-root="deque-tree-root">
                    <div class="deque-tree-leaf"></div>
                    <span class="deque-tree-label">Homecoming</span>
                  </li>
                  <li role="treeitem" aria-label="Multicultural Assembly" data-tree-root="deque-tree-root">
                    <div class="deque-tree-leaf"></div>
                    <span class="deque-tree-label">Multicultural Assembly</span>
                  </li>
                </ul>
              </li>
            </ul>
          </li>
          <li role="treeitem" aria-label="For Parents" data-tree-root="deque-tree-root">
            <div class="deque-tree-branch"></div>
            <span class="deque-tree-label">For Parents</span>
            <ul role="group">
              <li role="treeitem" aria-label="Parent-Teacher Conference" data-tree-root="deque-tree-root">
                <div class="deque-tree-leaf"></div>
                <span class="deque-tree-label">Parent-Teacher Conference</span>
              </li>
            </ul>
          </li>
        </ul>
      </li>
    </ul>
  </div>
</div>

JavaScript

Required: The complete JavaScript file (for all patterns in the library): deque-patterns.min.js

Key parts of the JavaScript file

Note: The code below is functional only in the context of the complete JavaScript file.

In the @tree section:


      var behaviors = {
        default: _noSelection2.default,
        single: _singleSelection2.default,
        multi: _multiSelection2.default
      };

      function createTree(config, treeGroup) {

        var treeRoot = treeGroup.querySelector('[role="tree"]');

        if (config.selectStyle === 'multi') {
          treeRoot.setAttribute('aria-multiselectable', 'true');
        }

        var treeItems = treeGroup.querySelectorAll('[role="treeitem"]');
        for (var x = 0; x < treeItems.length; x++) {
          treeItems[x].setAttribute('tabindex', '-1');
          var behavior = behaviors[config.selectStyle] || behaviors.default;
          behavior(treeItems[x]);
          if (x === 0) {
            (0, _NodeUtils.focusOnNode)(treeItems[x]);
            if (config.selectStyle) {
              (0, _NodeUtils.selectNode)(treeItems[x]);
            }
          }
          if (treeItems[x].children) {
            treeItems[x].setAttribute('aria-expanded', 'false');
          }
        }
      }
      

In the @guidUtils section:


      /*
        note - not a true guid. I prepend 'g' because 
        the ID of an element cannot start with a numeral
      */

      function generateGuid() {
        var S4 = function S4() {
          return ((1 + Math.random()) * 0x10000 | 0).toString(16).substring(1);
        };
        return 'g' + (S4() + S4() + '-' + S4() + '-' + S4() + '-' + S4() + '-' + S4() + S4() + S4());
      }
      

In the @keyboardUtils section:


      var KEYS = exports.KEYS = {
        BACKSPACE: 8,
        TAB: 9,
        ENTER: 13,
        SHIFT: 16,
        CTRL: 17,
        ALT: 18,
        ESCAPE: 27,
        SPACE: 32,
        LEFT: 37,
        RIGHT: 39,
        UP: 38,
        DOWN: 40,
        F10: 121,
        HOME: 36,
        END: 35,
        PAGE_UP: 33,
        PAGE_DOWN: 34
      };

      function bindElementToEventValue(element, eventName, testValue, handler) {
        function localHandler(e) {
          if (e.which === testValue) {
            handler(e);
          }
        }

        element.addEventListener(eventName, localHandler);
        return function () {
          element.removeEventListener(eventName, localHandler);
        };
      }

      function bindElementToKeypressValue(element, testValue, handler) {
        return bindElementToEventValue(element, 'keypress', testValue, handler);
      }

      function bindElementToKeydownValue(element, testValue, handler) {
        return bindElementToEventValue(element, 'keydown', testValue, handler);
      }

      function onElementEnter(element, handler) {
        return bindElementToKeydownValue(element, KEYS.ENTER, handler);
      }

      function onElementEscape(element, handler) {
        return bindElementToKeydownValue(element, KEYS.ESCAPE, handler);
      }

      function onElementSpace(element, handler) {
        return bindElementToKeypressValue(element, KEYS.SPACE, handler);
      }

      function onElementLeft(element, handler) {
        return bindElementToKeydownValue(element, KEYS.LEFT, handler);
      }

      function onElementRight(element, handler) {
        return bindElementToKeydownValue(element, KEYS.RIGHT, handler);
      }

      function onElementUp(element, handler) {
        return bindElementToKeydownValue(element, KEYS.UP, handler);
      }

      function onElementDown(element, handler) {
        return bindElementToKeydownValue(element, KEYS.DOWN, handler);
      }

      function onElementHome(element, handler) {
        return bindElementToKeydownValue(element, KEYS.HOME, handler);
      }

      function onElementEnd(element, handler) {
        return bindElementToKeydownValue(element, KEYS.END, handler);
      }

      function onElementPageUp(element, handler) {
        return bindElementToKeydownValue(element, KEYS.PAGE_UP, handler);
      }

      function onElementPageDown(element, handler) {
        return bindElementToKeydownValue(element, KEYS.PAGE_DOWN, handler);
      }

      function onElementF10(element, handler) {
        return bindElementToKeydownValue(element, KEYS.F10, handler);
      }

      function isAlphaNumeric(charCode) {
        return charCode >= 48 && charCode <= 90 /* numbers, uppercase letters */
          || charCode >= 97 && charCode <= 122 /* lowercase letters */;
      }

      function onElementCharacter(element, handler) {
        function localHandler(e) {
          var charCode = e.which;
          if (isAlphaNumeric(charCode)) {
            handler(e);
          }
        }

        element.addEventListener('keypress', localHandler);
        return function () {
          element.removeEventListener('keypress', localHandler);
        };
      }

      function trapEnter(element) {
        onElementEnter(element, function (e) {
          e.stopPropagation();
          e.preventDefault();
        });
      }
      

In the @tree-NodeUtils section:




      function getAllTreeNodes(treeID) {
        return (0, _selectionUtils.queryAll)('#' + treeID + ' [role="treeitem"]');
      }

      /* public API starts here */

      function hasChildren(node) {
        return node.hasAttribute('aria-expanded');
      }

      function isOpen(node) {
        return node.getAttribute('aria-expanded') === 'true';
      }

      function getRootNode(treeID) {
        return document.querySelector('#' + treeID + ' > ul > [role=\'treeitem\']');
      }

      function getVisibleNodes(treeID) {
        return (0, _selectionUtils.queryAll)('#' + treeID + ' > ul > [role=\'treeitem\'], #' + treeID + ' [aria-expanded=\'true\'] > ul > [role=\'treeitem\']');
      }

      function getPreviousVisibleNode(node) {
        var treeID = node.getAttribute('data-tree-root');
        var nodes = getVisibleNodes(treeID);

        return getNextNodeInSequence(node, nodes.reverse()) || node;
      }

      function getNextVisibleNode(node) {
        var treeID = node.getAttribute('data-tree-root');
        var nodes = getVisibleNodes(treeID);
        return getNextNodeInSequence(node, nodes) || node;
      }

      function focusOnNode(node) {
        var treeID = node.getAttribute('data-tree-root');

        getAllTreeNodes(treeID).forEach(function (item) {
          item.setAttribute('tabindex', '-1');
        });

        node.setAttribute('tabindex', '0');
        node.focus();
        return node;
      }

      function closeNode(node) {
        node.setAttribute('aria-expanded', 'false');
        return node;
      }

      function openNode(node) {
        node.setAttribute('aria-expanded', 'true');
        return node;
      }

      function getFirstChild(node) {
        return node.querySelector('[role="treeitem"]') || node;
      }

      function getParent(node) {
        if (node.parentNode.parentNode.getAttribute('role') === 'treeitem') {
          return node.parentNode.parentNode;
        }

        return node;
      }

      function getNextSibling(node) {
        return node.nextElementSibling || node;
      }

      function getPreviousSibling(node) {
        return node.previousSibling || node;
      }

      function selectNode(node, multimode) {
        var treeID = node.getAttribute('data-tree-root');

        getAllTreeNodes(treeID).forEach(function (item) {
          if (node === item) {
            item.setAttribute('aria-selected', 'true');
          } else if (multimode) {
            item.setAttribute('aria-selected', 'false');
          } else {
            item.removeAttribute('aria-selected');
          }
        });

        node.setAttribute('aria-selected', 'true');
        return node;
      }

      function addNodeToSelection(node) {
        node.setAttribute('aria-selected', 'true');
      }

      function isSelected(node) {
        return node.getAttribute('aria-selected') === 'true';
      }

      function toggleNodeSelection(node, multimode) {
        if (node.getAttribute('aria-selected') === 'true') {
          if (multimode) {
            node.setAttribute('aria-selected', 'false');
          } else {
            node.removeAttribute('aria-selected');
          }
        } else {
          node.setAttribute('aria-selected', 'true');
        }
      }
      

In the @tree-selectionModes-multiSelection section:


      var shifting = false;

      function onDocKey(e) {
        shifting = e.shiftKey;
      }

      function applyLogic(node) {
        node.setAttribute('aria-selected', 'false');

        document.removeEventListener('keydown', onDocKey);
        document.removeEventListener('keyup', onDocKey);
        document.addEventListener('keydown', onDocKey);
        document.addEventListener('keyup', onDocKey);

        node.addEventListener('click', function (e) {
          e.preventDefault();
          e.stopPropagation();

          var method = shifting ? 'addNodeToSelection' : 'selectNode';

          if (NodeUtils.isOpen(node)) {
            NodeUtils.closeNode(node);
          } else if (NodeUtils.hasChildren(node)) {
            NodeUtils.openNode(node);
          }

          NodeUtils.focusOnNode(node);
          NodeUtils[method](node, true);
        });

        KeyboardUtils.onElementEnter(node, function (e) {
          e.preventDefault();
          e.stopPropagation();

          var activateable = void 0;
          for (var i = 0; i < node.children.length; i++) {
            var item = node.children[i];
            if (item.tagName === 'A' || item.tagName === 'BUTTON') {
              activateable = item;
              break;
            }
          }

          if (activateable) {
            activateable.click();
          }
        });

        KeyboardUtils.onElementSpace(node, function (e) {
          e.preventDefault();
          e.stopPropagation();
          if (e.ctrlKey) {
            NodeUtils.toggleNodeSelection(node, true);
          }
        });

        KeyboardUtils.onElementCharacter(node, function (e) {
          e.preventDefault();
          e.stopPropagation();

          var character = String.fromCharCode(e.which).toUpperCase();
          var nodes = NodeUtils.getVisibleNodes(node.getAttribute('data-tree-root'));

          var thisFound = false;
          var target = nodes.filter(function (item) {
            // discard 'previous' nodes:
            if (thisFound) {
              return true;
            }
            if (item === node) {
              thisFound = true;
            }
            return false;
          }).reduce(function (acc, val) {
            // grab next one starting with char
            if (acc) {
              return acc;
            }
            if (val.innerText.charAt(0).toUpperCase() === character) {
              return val;
            }
          }, null);

          if (target) {
            NodeUtils.focusOnNode(target);
            NodeUtils.selectNode(target, true);
          }
        });

        KeyboardUtils.onElementHome(node, function (e) {
          e.preventDefault();
          e.stopPropagation();
          var target = NodeUtils.getRootNode(node.getAttribute('data-tree-root'));
          NodeUtils.focusOnNode(target);

          if (e.shiftKey) {
            var current = NodeUtils.getPreviousVisibleNode(node);
            if (current.getAttribute('aria-selected') === node.getAttribute('aria-selected')) {
              NodeUtils.toggleNodeSelection(node, true);
            }

            while (current !== target) {
              NodeUtils.toggleNodeSelection(current, true);
              current = NodeUtils.getPreviousVisibleNode(current);
            }

            NodeUtils.toggleNodeSelection(target, true);
          } else {
            NodeUtils.selectNode(target, true);
          }
        });

        KeyboardUtils.onElementEnd(node, function (e) {
          e.preventDefault();
          e.stopPropagation();
          var nodes = NodeUtils.getVisibleNodes(node.getAttribute('data-tree-root'));
          var target = nodes.reverse()[0];
          NodeUtils.focusOnNode(target);

          if (e.shiftKey) {
            var current = NodeUtils.getNextVisibleNode(node);

            if (current.getAttribute('aria-selected') === node.getAttribute('aria-selected')) {
              NodeUtils.toggleNodeSelection(node, true);
            }

            while (current !== target) {
              NodeUtils.toggleNodeSelection(current, true);
              current = NodeUtils.getNextVisibleNode(current);
            }

            NodeUtils.toggleNodeSelection(target, true);
          } else {
            NodeUtils.selectNode(target, true);
          }
        });

        KeyboardUtils.onElementDown(node, function (e) {
          e.preventDefault();
          e.stopPropagation();
          var sibling = NodeUtils.getNextVisibleNode(node);
          NodeUtils.focusOnNode(sibling);

          if (e.ctrlKey) {
            return;
          }
          if (e.shiftKey) {
            if (sibling.getAttribute('aria-selected') === node.getAttribute('aria-selected')) {
              return NodeUtils.toggleNodeSelection(node, true);
            }
            return NodeUtils.toggleNodeSelection(sibling, true);
          }

          NodeUtils.selectNode(sibling, true);
        });

        KeyboardUtils.onElementUp(node, function (e) {
          e.preventDefault();
          e.stopPropagation();
          var sibling = NodeUtils.getPreviousVisibleNode(node);
          NodeUtils.focusOnNode(sibling);

          if (e.ctrlKey) {
            return;
          }
          if (e.shiftKey) {
            if (sibling.getAttribute('aria-selected') === node.getAttribute('aria-selected')) {
              return NodeUtils.toggleNodeSelection(node, true);
            }

            return NodeUtils.toggleNodeSelection(sibling, true);
          }

          NodeUtils.selectNode(sibling, true);
        });

        KeyboardUtils.onElementRight(node, function (e) {
          e.preventDefault();
          e.stopPropagation();

          if (NodeUtils.hasChildren(node) && !NodeUtils.isOpen(node)) {
            NodeUtils.openNode(node);
          } else {
            var child = NodeUtils.getFirstChild(node);
            NodeUtils.focusOnNode(child);

            if (e.ctrlKey) {
              return;
            }
            if (e.shiftKey) {
              return NodeUtils.addNodeToSelection(child);
            }

            NodeUtils.selectNode(child, true);
          }
        });

        KeyboardUtils.onElementLeft(node, function (e) {
          e.preventDefault();
          e.stopPropagation();

          if (!NodeUtils.hasChildren(node) || !NodeUtils.isOpen(node)) {
            var parent = NodeUtils.getParent(node);
            NodeUtils.focusOnNode(parent);

            if (e.ctrlKey) {
              return;
            }
            if (e.shiftKey) {
              return NodeUtils.addNodeToSelection(parent);
            }

            return NodeUtils.selectNode(parent, true);
          }

          return NodeUtils.closeNode(node);
        });
      }
      

In the @tree-selectionModes-noSelection section:


      function applyLogic(node) {
        node.addEventListener('click', function (e) {
          e.preventDefault();
          e.stopPropagation();

          NodeUtils.focusOnNode(node);

          if (NodeUtils.isOpen(node)) {
            NodeUtils.closeNode(node);
          } else if (NodeUtils.hasChildren(node)) {
            NodeUtils.openNode(node);
          }
        });

        KeyboardUtils.onElementEnter(node, function (e) {
          e.preventDefault();
          e.stopPropagation();

          var activateable = void 0;
          for (var i = 0; i < node.children.length; i++) {
            var item = node.children[i];
            if (item.tagName === 'A' || item.tagName === 'BUTTON') {
              activateable = item;
              break;
            }
          }

          if (activateable) {
            var click = new MouseEvent('click');
            activateable.dispatchEvent(click);
          }
        });

        KeyboardUtils.onElementCharacter(node, function (e) {
          e.preventDefault();
          e.stopPropagation();

          var character = String.fromCharCode(e.which).toUpperCase();
          var nodes = NodeUtils.getVisibleNodes(node.getAttribute('data-tree-root'));

          var thisFound = false;
          var target = nodes.filter(function (item) {
            // discard 'previous' nodes:
            if (thisFound) {
              return true;
            }
            if (item === node) {
              thisFound = true;
            }
            return false;
          }).reduce(function (acc, val) {
            // grab next one starting with char
            if (acc) {
              return acc;
            }
            if (val.innerText.charAt(0).toUpperCase() === character) {
              return val;
            }
          }, null);

          if (target) {
            NodeUtils.focusOnNode(target);
          }
        });

        KeyboardUtils.onElementHome(node, function (e) {
          e.preventDefault();
          e.stopPropagation();
          var target = NodeUtils.getRootNode(node.getAttribute('data-tree-root'));
          NodeUtils.focusOnNode(target);
        });

        KeyboardUtils.onElementEnd(node, function (e) {
          e.preventDefault();
          e.stopPropagation();
          var nodes = NodeUtils.getVisibleNodes(node.getAttribute('data-tree-root'));
          var target = nodes.reverse()[0];
          NodeUtils.focusOnNode(target);
        });

        KeyboardUtils.onElementDown(node, function (e) {
          e.preventDefault();
          e.stopPropagation();
          var sibling = NodeUtils.getNextVisibleNode(node);
          NodeUtils.focusOnNode(sibling);
        });

        KeyboardUtils.onElementUp(node, function (e) {
          e.preventDefault();
          e.stopPropagation();
          var sibling = NodeUtils.getPreviousVisibleNode(node);
          NodeUtils.focusOnNode(sibling);
        });

        KeyboardUtils.onElementRight(node, function (e) {
          e.preventDefault();
          e.stopPropagation();

          if (NodeUtils.hasChildren(node) && !NodeUtils.isOpen(node)) {
            NodeUtils.openNode(node);
          } else {
            var child = NodeUtils.getFirstChild(node);
            NodeUtils.focusOnNode(child);
          }
        });

        KeyboardUtils.onElementLeft(node, function (e) {
          e.preventDefault();
          e.stopPropagation();

          if (!NodeUtils.hasChildren(node) || !NodeUtils.isOpen(node)) {
            var parent = NodeUtils.getParent(node);
            NodeUtils.focusOnNode(parent);

            return parent;
          }

          return NodeUtils.closeNode(node);
        });
      }
      

In the @tree-selectionModes-singleSelection section:


      function applyLogic(node) {
        node.addEventListener('click', function (e) {
          e.preventDefault();
          e.stopPropagation();

          NodeUtils.focusOnNode(node);
          NodeUtils.selectNode(node);

          if (NodeUtils.isOpen(node)) {
            NodeUtils.closeNode(node);
          } else if (NodeUtils.hasChildren(node)) {
            NodeUtils.openNode(node);
          }
        });

        KeyboardUtils.onElementEnter(node, function (e) {
          e.preventDefault();
          e.stopPropagation();

          var activateable = void 0;
          for (var i = 0; i < node.children.length; i++) {
            var item = node.children[i];
            if (item.tagName === 'A' || item.tagName === 'BUTTON') {
              activateable = item;
              break;
            }
          }

          if (activateable) {
            var click = new MouseEvent('click');
            activateable.dispatchEvent(click);
          }
        });

        KeyboardUtils.onElementSpace(node, function (e) {
          e.preventDefault();
          e.stopPropagation();
          if (e.ctrlKey) {
            NodeUtils.toggleNodeSelection(node);
          }
        });

        KeyboardUtils.onElementCharacter(node, function (e) {
          e.preventDefault();
          e.stopPropagation();

          var character = String.fromCharCode(e.which).toUpperCase();
          var nodes = NodeUtils.getVisibleNodes(node.getAttribute('data-tree-root'));

          var thisFound = false;
          var target = nodes.filter(function (item) {
            // discard 'previous' nodes:
            if (thisFound) {
              return true;
            }
            if (item === node) {
              thisFound = true;
            }
            return false;
          }).reduce(function (acc, val) {
            // grab next one starting with char
            if (acc) {
              return acc;
            }
            if (val.innerText.charAt(0).toUpperCase() === character) {
              return val;
            }
          }, null);

          if (target) {
            NodeUtils.focusOnNode(target);
            NodeUtils.selectNode(target);
          }
        });

        KeyboardUtils.onElementHome(node, function (e) {
          e.preventDefault();
          e.stopPropagation();
          var target = NodeUtils.getRootNode(node.getAttribute('data-tree-root'));
          NodeUtils.focusOnNode(target);
          NodeUtils.selectNode(target);
        });

        KeyboardUtils.onElementEnd(node, function (e) {
          e.preventDefault();
          e.stopPropagation();
          var nodes = NodeUtils.getVisibleNodes(node.getAttribute('data-tree-root'));
          var target = nodes.reverse()[0];
          NodeUtils.focusOnNode(target);
          NodeUtils.selectNode(target);
        });

        KeyboardUtils.onElementDown(node, function (e) {
          e.preventDefault();
          e.stopPropagation();
          var sibling = NodeUtils.getNextVisibleNode(node);
          NodeUtils.focusOnNode(sibling);

          if (!e.ctrlKey) {
            NodeUtils.selectNode(sibling);
          }
        });

        KeyboardUtils.onElementUp(node, function (e) {
          e.preventDefault();
          e.stopPropagation();
          var sibling = NodeUtils.getPreviousVisibleNode(node);
          NodeUtils.focusOnNode(sibling);

          if (!e.ctrlKey) {
            NodeUtils.selectNode(sibling);
          }
        });

        KeyboardUtils.onElementRight(node, function (e) {
          e.preventDefault();
          e.stopPropagation();

          if (NodeUtils.hasChildren(node) && !NodeUtils.isOpen(node)) {
            NodeUtils.openNode(node);
          } else {
            var child = NodeUtils.getFirstChild(node);
            NodeUtils.focusOnNode(child);

            if (!e.ctrlKey) {
              NodeUtils.selectNode(child);
            }
          }
        });

        KeyboardUtils.onElementLeft(node, function (e) {
          e.preventDefault();
          e.stopPropagation();

          if (!NodeUtils.hasChildren(node) || !NodeUtils.isOpen(node)) {
            var parent = NodeUtils.getParent(node);
            NodeUtils.focusOnNode(parent);

            if (!e.ctrlKey) {
              NodeUtils.selectNode(parent);
            }

            return parent;
          }

          return NodeUtils.closeNode(node);
        });
      }
      

Required: Initialization JavaScript (with functionality specific to individual pattern instances):

var treeGroup = document.querySelector('#deque-tree-no-select');

function renderTree() {
  deque.createTree({selectStyle: ''}, treeGroup);
}

renderTree();

CSS

Required: The complete CSS file (for all patterns in the library): deque-patterns.min.css

Key styles within the CSS file (other styles may also be necessary):


.deque-tree [role='tree'] {
  list-style-type: none;
}
.deque-tree [role='tree'] ul {
  list-style-type: none;
  padding: 0 0 0 15px;
}
.deque-tree [role='tree'] li[aria-expanded='false'] ul {
  display: none;
}
.deque-tree [role='tree'] li[role='treeitem'] {
  cursor: pointer;
  user-select: none;
  outline: none;
  display: inline-block;
  width: 100%;
  color: #767676;
}
.deque-tree [role='tree'] li[role='treeitem']:focus {
  outline: 1px dashed #000000;
}
.deque-tree [role='tree'] li[role='treeitem'] .deque-tree-leaf:before {
  content: '';
  float: left;
  margin-right: 5px;
  font-family: 'mwf-glyphs';
  color: #000000;
  font-size: 0.6em;
}
.deque-tree [role='tree'] li[role='treeitem'] .deque-tree-label {
  padding-bottom: 1px;
}
.deque-tree [role='tree'] li[role='treeitem'][aria-selected='true'] > .deque-tree-label {
  color: #000000;
  border-bottom: 1px solid #000000;
  padding-bottom: 0;
}
.deque-tree [role='tree'] .deque-tree-branch {
  float: left;
  margin-right: 10px;
}
.deque-tree [role='tree'] [role='treeitem'][aria-expanded='true'] > .deque-tree-branch:after {
  content: '\E70D';
  font-family: 'mwf-glyphs';
}
.deque-tree [role='tree'] [role='treeitem'][aria-expanded='false'] > .deque-tree-branch:after {
  content: '\E76C';
  font-family: 'mwf-glyphs';
}

Fonts

Note: You will need to edit the src for font-family:'mwf-glyphs' in the external CSS file.

Implementation Instructions

Implementing the Tree pattern is done when you want to have an accessible way to navigate hierarchical lists (for example, a file directory navigator). This includes providing visual distinction between focused and selected items (nodes) that may be either expanded or collapsed in the tree that is also communicated by non-visual means. Navigation and selection must be possible with both keyboard and mouse operations.

Prerequisites:

WARNING: Screen Reader support for tree components is spotty at best. For optimal screen reader support, consider using a Hierarchical Menu pattern.

Step 1: Add Dependencies

Add deque-patterns.min.css in the <head> of the document.

<link rel="stylesheet" type="text/css" href="deque-patterns.min.css">

Add a script link to deque-patterns.min.js to the bottom of the page.

<script type="text/javascript" src="deque-patterns.min.js"></script>

Step 2: Add HTML

  • Wrap the HTML in <div> container with a unique ID and class="deque-tree", for styling purposes.
    • Create a <div> container with id="deque-tree-root".
      • Add a <ul> container with role="tree"
        • For each tree item:
        • Create a <li> container with role="treeitem", data-tree-root="deque-tree-root", and the attribute aria-label that states the name of that tree item.
          • Add a <div> container. If the item has children add class="deque-tree-branch". If the item has no children add <div> container with class="deque-tree-leaf".
          • Create a <span> container with class="deque-tree-label". The inner text is the name of that tree item.
          • If this item has any children include <ul> container with role="group". For each child repeat the process listed above.

Step 2: Add JavaScript

  • Select the tree group using its unique ID.
  • Pass the tree group into deque.createTree()

The createTree function takes two arguments. The first is the tree group you previously selected. The second is config object. Refer to the following usage description for the config object:

  • Config Object: This is an object with one optional key: selectStyle. If you leave this key out, there is no select style and navigating the tree will merely focus nodes, never setting aria-selected anywhere. If you pass in the string "single" then the tree will allow a single selected item. As you navigate the tree, the item will generally change with focus (unless you are holding CTRL, in which case focus can change without changing selection). Finally, if you pass in the string "multi", your tree will support multiple selection. Hold SHIFT while navigating to select multiple nodes. Holding CTRL will again prevent selection change.