Listas hierárquicas (Trees)

Organizar dados hierarquicamente é uma prática muito comum desde os primórdios da humanidade. Exemplos de tais organizações são os sumários de livros, árvores genealógicas, estruturas de cargos em uma empresa, entre outros. Uma representação frequentemente utilizada em sistemas computacionais para listas hierárquicas é a de árvore. Essa metáfora se refere à estrutura de divisão do raíz em galhos (ou nós) e, por fim, em folhas. Exemplos de tais aplicações são os navegadores de arquivos, como o Windows Explorer e o Nautilus para distribuições Linux. Nelas, a raíz é a unidade de armazenamento, os galhos (ou nós) são as pastas de arquivos e as folhas são os arquivos.

Na linguagem de marcação HTML, estruturas hierárquicas podem ser representadas em listas ordenadas (elemento <ol>) ou não ordenadas (elemento <ul>). Porém, tais estruturas são estáticas, ou seja, não é possível controlar a visibilidade dos elementos, muito útil para listas longas e/ou com diversos níveis de profundidade.

Esse tipo de funcionalidade, semelhante aos navegadores de arquivo, pode ser obtida com o uso de JavaScript e CSS. No entanto, semanticamente elas não são entendidas como tal, pois não são marcadas no documento HTML. Para solucionar este problema é possível usar os papeis tree e treeitem do WAI-ARIA.

Um lista do tipo tree é uma lista que pode conter grupos de itens aninhados em sub-níveis que podem ser expandidos (mostra os descendentes do grupo) ou contraídos (esconde os descentes do grupo).

Propriedades para o papel tree: aria-activedescendant, aria-atomic, aria-controls, aria-describedat, aria-describedby, aria-dropeffect, aria-flowto, aria-haspopup, aria-label, aria-labelledby, aria-live, aria-multiselectable, aria-orientation, aria-owns, aria-relevant, aria-required.

Atenção: Um elemento com o papel tree deve possuir (owned) obrigatoriamente elementos com papeis: treeitem e group.

Estados para o papel tree: aria-busy, aria-disabled, aria-expanded, aria-grabbed, aria-hidden, aria-invalid.

Propriedades para o papel treeitem: aria-atomic, aria-controls, aria-describedat, aria-describedby, aria-dropeffect, aria-flowto, aria-haspopup, aria-label, aria-labelledby, aria-level, aria-live, aria-owns, aria-posinset, aria-relevant, aria-setsize.

Estados para o papel treeitem: aria-busy, aria-checked, aria-disabled, aria-expanded, aria-grabbed, aria-hidden, aria-invalid.

Propriedades para o papel group: aria-activedescendant, aria-atomic, aria-controls, aria-describedat, aria-describedby, aria-dropeffect, aria-flowto, aria-haspopup, aria-label, aria-labelledby, aria-live, aria-owns, aria-relevant.

Estados para o papel group: aria-busy, aria-disabled, aria-expanded, aria-grabbed, aria-hidden, aria-invalid.

Exemplo: Código com WAI-ARIA

Dica: Veja como testar o exemplo em Como testar o seu código.


Documento: tree.html.

<!DOCTYPE html>
<html lang="pt-br">
	<head>
		<meta charset="utf-8">
		<title>Exemplo do Papel Árvore</title> 

        <link rel="stylesheet" type="text/css" href="estilo.css">
		<script type="text/javascript" src="../libs/jquery.js"></script>
        <script type="text/javascript" src="script.js"> </script>
	
		<script src="../libs/html5.js"></script>
	</head>
	<body>
		<h1>Papel Árvore (<span lang="en">Tree</span>)</h1>

		<div role="main" class="main">
		<div class="treeview">
				<ul class="tree">
					<li><a href="#flora">Flora</a>
						<ul class="children">
							<li><a href="#ervas">Ervas</a>
								<ul class="children">
									<li><a href="#ervadoce">Erva Doce</a></li>
									<li><a href="#urtiga">Urtiga</a></li>
									<li><a href="#boldo">Boldo</a>
										<ul class="children">
											<li><a href="#africano">Africano</a></li>
											<li><a href="#indigena">Indígena</a></li>
											<li><a href="#miudo">Miúdo</a></li>
										</ul>
									</li>
								</ul>
							</li>
							<li><a href="#flores">Flores</a>
								<ul class="children">
									<li><a href="#margarida">Margarida</a></li>
									<li><a href="#cravo">Cravo</a></li>
									<li><a href="#rosa">Rosa</a></li>
								</ul>
							</li>
						</ul>
					</li>
					<li><a href="#fauna">Fauna</a>
						<ul class="children">
							<li><a href="#mamiferos">Mamíferos</a>
								<ul class="children">
									<li><a href="#onca">Onça pintada</a></li>
									<li><a href="#mico">Mico-leão-dourado</a></li>
									<li><a href="#capivara">Capivara</a></li>
								</ul>
							</li>
							<li><a href="#repteis">Répteis</a>
								<ul class="children">
									<li><a href="#lagarto">Lagarto</a>
										<ul class="children">
											<li><a href="#calango">Calango-verde</a></li>
											<li><a href="#teiu">Teiú</a></li>
											<li><a href="#teju">Lagarto-teju</a></li>
										</ul>									
									</li>
									<li><a href="#cobra">Cobra</a></li>
									<li><a href="#tartaruga">Tartaruga</a></li>
								</ul>
							</li>
						</ul>
					</li>
				</ul>
			</div>
		</div>
		<footer id="footer" role="contentinfo">
			<p>Este exemplo é uma adaptação de 
			<a href="http://accessibleculture.org/research-files/aria-tree-views/treeviewB.html#flowers">
			Accessible Culture</a>, distribuída sob a licença <a rel="license" 
			href="http://creativecommons.org/licenses/by-nc-sa/3.0/">
			Creative Commons Attribution-NonCommercial-ShareAlike 3.0 License</a>.</p>
		</footer>
	</body>
</html>

Folha de estilo: estilo.css.

header, nav, footer {display: block;}
#siteName {
	border:none; 
	margin: 0; 
	padding:0;
}
ul {padding-left: 20px;}
li {
	list-style-type: none; 
	outline: none; 
	padding: 5px;
}
li ul {margin-top: 5px;}
.visually-hidden {
	position: absolute; 
	left: -999em;
} 
.tree {
	padding: 10px; 
	margin-bottom: 2em; 
	border: 1px solid #999;
}
.hasChildren {position: relative;}
.tree li ul {display: none;}
.tree a {padding: 2px 5px 2px 0;}
.tree a:focus {outline: 2px dotted #f00;}
.tree li li a {
	background-image: url("img/raquo-blue.png");
	background-repeat: no-repeat;
	background-position: 8px 0.5em;
	padding-left: 40px;
}
.tree .hasChildren a {
	background-image: none; 
	padding-left: 30px;
}
.tree li .noChildren a {
	background-image: url("img/raquo-blue.png"); 
	padding-left: 25px;
}
.toggle {
	background-position: left top; 
	background-repeat: no-repeat; 
	cursor: pointer; 
	height: 14px; 
	width: 14px; 
	position: absolute; 
	left: 10px; 
	top: 0.4em;
}
.tree .expanded {background-image: url("img/minus-blue.png");}
.tree .collapsed {background-image: url("img/plus-blue.png");}
.tree .expanded.hover {background-image: url("img/minus-blue-hover.png");}
.tree .collapsed.hover {background-image: url("img/plus-blue-hover.png");}
#footer {clear: both;}

Script: script.js.

$(document).ready(function() {
	if ($('.treeview').length) {
		//atribui a primeira lista não ordenada que estiver dentro do div 
		//com a classe treeview, pois é a árvore
		var $tree = $('.treeview ul:first');
		$tree.attr({'role': 'tree'});
		//variáveis que mantém o controle sobre os nós expandidos ou contraídos da árvore
		var $allNodes = $('li:visible', $tree);//lista de nós visíveis da árvore
		var lastNodeIdx = $allNodes.length - 1;//o índice do último nó visível da lista
		var $lastNode = $allNodes.eq(lastNodeIdx);//último nó visível da lista
		
		//expande ou contrai um grupo de nós
		function toggleGroup($node) {
			$toggle = $('> div', $node);
			$childList = $('> ul', $node);
			
			//expande ou contraí os nós do grupo com efeito visual slide
			$childList.slideToggle('fast', function() {
					//atualiza as variáveis de controle sobre os nós expandidos ou contraídos
					$allNodes = $('li:visible', $tree);
					lastNodeIdx = $allNodes.length - 1;
					$lastNode = $allNodes.eq(lastNodeIdx);
				}
			);
			//ajuste de estilo e propriedades wai-aria da contração ou expansão do grupo
			if ($toggle.hasClass('collapsed')) {
				//ajuste de estilo visual para expandido
				$toggle.removeClass('collapsed').addClass('expanded'); 
				//indica que um elemento está expandido (semanticamente e não visualmente)
				$('> a', $node).attr({'aria-expanded': 'true', 'tabindex': '0'}).focus(); 
			} else {
				//ajuste de estilo visual para contraído
				$toggle.removeClass('expanded').addClass('collapsed'); 
				//indica que um elemento está contraído (semanticamente e não visualmente)
				$('> a', $node).attr({'aria-expanded': 'false', 'tabindex': '0'}).focus(); 
			}
		}
		
		//obtém o próximo nó da árvore
		function nextNodeLink($el, dir) {
			var thisNodeIdx = $allNodes.index($el.parent());
			if (dir == 'up' || dir == 'parent') {
				var endNodeIdx = 0;
				var operand = -1;
			} else {
				var endNodeIdx = lastNodeIdx;
				var operand = 1;
			}
			if (thisNodeIdx == endNodeIdx) {//se o nós atual for o último
				return false; //não faz nada
			}

			if (dir == 'parent') {
				var parentNodeIdx = $allNodes.index($el.parent().parent().parent());
				var $nextEl = $('> a', $allNodes.eq(parentNodeIdx));
			} else {
				var $nextEl = $('> a', $allNodes.eq(thisNodeIdx + operand));
			}

			$el.attr('tabindex', '-1');
			$nextEl.attr('tabindex', '0').focus();
			
		}
		
		//para cada link que houver na árvore
		$('li > a', $tree).each(function() {
				var $el = $(this);
				var $node = $el.parent();
				$el.attr({'role': 'treeitem', 'aria-selected': 'false', 
						  'tabindex': "-1", 'aria-label': $el.text()});
				$node.attr('role', 'presentation');
				//se o nó tem nós filhos
				if ($node.has('ul').length) {
					$node.addClass('hasChildren');
					$childList = $('ul', $node);
					$childList.attr({'role': 'group'}).hide();
					//adiciona o elemento para expandir/contrair e define 
					//aria-expanded no link
					$('<div aria-hidden="true" class="toggle collapsed">').insertBefore($el);
					$el.attr('aria-expanded', 'false');
				} else {//caso o nó não tenha nós filhos
					$node.addClass('noChildren');
				}
				//define os eventos de teclado
				$el.on('keydown', function(e){
						if (!(e.shiftKey || e.ctrlKey || e.altKey || e.metaKey)) {
							switch(e.which) {
								case 38: //cima
									e.preventDefault();
									nextNodeLink($(this), 'up');
									break;
								case 40: //baixo
									e.preventDefault();
									nextNodeLink($(this), 'down');
									break;
								case 37: //esquerda
									if ($(this).attr('aria-expanded') == 'false' || 
										$node.is('.noChildren')) {
										nextNodeLink($(this), 'parent');
									} else {
										toggleGroup($node);
									}
									break;
								case 39: //direita
									if ($(this).attr('aria-expanded') == 'true') {
										nextNodeLink($(this), 'down');
									} else {
										toggleGroup($node);
									}
									break;
							}
						}
					}
				//atualiza aria-selected quando o estado de foco de um nós muda
				).on('focus', function() {
						$('[aria-selected="true"]', $tree).attr('aria-selected', 'false');
						$(this).attr('aria-selected', 'true');
					}
				);
			}
		);
		
		//define tabindex="0" no primeiro link da árvore
		$('> li:first > a', $tree).attr('tabindex', '0');
		
		//adiciona evento click e estilo hover sobre o elemento com classe toggle
		$('.toggle').on('click', 
			function() {
				toggleGroup($(this).parent());
			}
		).hover(
			function() {
				$(this).toggleClass('hover');
			}
		);
	}
});