Skip to content

Instantly share code, notes, and snippets.

@AndreasHnida
Created August 4, 2025 20:43
Show Gist options
  • Save AndreasHnida/6294ca486dad024843701179dad86f44 to your computer and use it in GitHub Desktop.
Save AndreasHnida/6294ca486dad024843701179dad86f44 to your computer and use it in GitHub Desktop.
File Tree Navigation

The key principles are: ARIA attributes for accessibility Roving tabindex pattern for keyboard navigation Clear visual indicators for focus and selection Standard keyboard shortcuts (arrows, Enter, Space) Here's a complete implementation:This implementation follows industry standards for accessible tree navigation:

Key Features:

ARIA Attributes: role="tree", role="treeitem", role="group" for proper screen reader support aria-expanded to indicate folder state aria-label for navigation context Roving Tabindex Pattern: Only one item has tabindex="0" at a time Other items have tabindex="-1" Tab key moves focus in/out of the tree, arrow keys navigate within Standard Keyboard Shortcuts: ↑/↓: Navigate between visible items : Expand folder or move to first child : Collapse folder or move to parent id:: 688fc8ec-e14f-47ad-9781-7950e74fcd14 Enter/Space: Select item or toggle folder Home/End: Jump to first/last item Visual Feedback: Clear focus outline (blue) Selected state (blue background) Hover state (gray background) Animated expand/collapse arrows

Implementation Notes:

The TreeNavigation class manages all keyboard interactions updateItemsList() dynamically tracks visible items (skips collapsed content) Focus management ensures only visible items can receive focus The selected class persists on the last activated item This approach is used by major UI libraries like Material-UI, Ant Design, and follows W3C ARIA Authoring Practices Guide recommendations for tree views.

<!DOCTYPE
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tree Navigation Menu</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 20px;
background: #f5f5f5;
}
.tree-container {
max-width: 400px;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
padding: 16px;
}
/* Tree structure */
.tree {
list-style: none;
padding: 0;
margin: 0;
}
.tree ul {
list-style: none;
padding-left: 24px;
margin: 0;
}
/* Tree items */
.tree-item {
margin: 2px 0;
}
.tree-item-content {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
/* Focus and hover states */
.tree-item-content:hover {
background: #f0f0f0;
}
.tree-item-content:focus {
outline: 2px solid #0066cc;
outline-offset: -2px;
background: #e6f2ff;
}
.tree-item-content.selected {
background: #0066cc;
color: white;
}
/* Icons */
.tree-icon {
width: 20px;
height: 20px;
margin-right: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.folder-icon::before {
content: "📁";
font-size: 16px;
}
.folder-icon.open::before {
content: "📂";
}
.file-icon::before {
content: "📄";
font-size: 16px;
}
/* Expand/collapse arrow */
.expand-icon {
width: 16px;
height: 16px;
margin-right: 4px;
transition: transform 0.2s ease;
opacity: 0.6;
}
.expand-icon::before {
content: "▶";
font-size: 10px;
}
.expand-icon.expanded {
transform: rotate(90deg);
}
.expand-icon.no-children {
visibility: hidden;
}
/* Hidden when collapsed */
.collapsed {
display: none;
}
/* Instructions */
.instructions {
margin-top: 20px;
padding: 16px;
background: #f9f9f9;
border-radius: 4px;
font-size: 14px;
color: #666;
}
.instructions h3 {
margin-top: 0;
color: #333;
}
.instructions kbd {
background: #eee;
padding: 2px 6px;
border-radius: 3px;
border: 1px solid #ccc;
font-family: monospace;
font-size: 12px;
}
</style>
</head>
<body>
<div class="tree-container">
<nav role="navigation" aria-label="File tree navigation">
<ul class="tree" role="tree" id="fileTree">
<li class="tree-item" role="treeitem" aria-expanded="false" data-type="folder">
<div class="tree-item-content" tabindex="0">
<span class="expand-icon"></span>
<span class="tree-icon folder-icon"></span>
<span class="tree-label">Another</span>
</div>
<ul role="group" class="collapsed">
<li class="tree-item" role="treeitem" data-type="file">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon no-children"></span>
<span class="tree-icon file-icon"></span>
<span class="tree-label">Subfilee1.txt</span>
</div>
</li>
</ul>
</li>
<li class="tree-item" role="treeitem" aria-expanded="false" data-type="folder">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon"></span>
<span class="tree-icon folder-icon"></span>
<span class="tree-label">Folder</span>
</div>
<ul role="group" class="collapsed">
<li class="tree-item" role="treeitem" data-type="file">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon no-children"></span>
<span class="tree-icon file-icon"></span>
<span class="tree-label">Document.pdf</span>
</div>
</li>
</ul>
</li>
<li class="tree-item" role="treeitem" aria-expanded="false" data-type="folder">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon"></span>
<span class="tree-icon folder-icon"></span>
<span class="tree-label">Folder</span>
</div>
<ul role="group" class="collapsed"></ul>
</li>
<li class="tree-item" role="treeitem" aria-expanded="true" data-type="folder">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon expanded"></span>
<span class="tree-icon folder-icon open"></span>
<span class="tree-label">Folder 1</span>
</div>
<ul role="group">
<li class="tree-item" role="treeitem" data-type="file">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon no-children"></span>
<span class="tree-icon file-icon"></span>
<span class="tree-label">Nochmal ne Liste</span>
</div>
</li>
</ul>
</li>
<li class="tree-item" role="treeitem" aria-expanded="false" data-type="folder">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon"></span>
<span class="tree-icon folder-icon"></span>
<span class="tree-label">Nice</span>
</div>
<ul role="group" class="collapsed"></ul>
</li>
<li class="tree-item" role="treeitem" aria-expanded="true" data-type="folder">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon expanded"></span>
<span class="tree-icon folder-icon open"></span>
<span class="tree-label">Some</span>
</div>
<ul role="group">
<li class="tree-item" role="treeitem" data-type="file">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon no-children"></span>
<span class="tree-icon file-icon"></span>
<span class="tree-label">List</span>
</div>
</li>
</ul>
</li>
<li class="tree-item" role="treeitem" aria-expanded="false" data-type="folder">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon"></span>
<span class="tree-icon folder-icon"></span>
<span class="tree-label">Test Folder 06:54:51</span>
</div>
<ul role="group" class="collapsed"></ul>
</li>
<li class="tree-item" role="treeitem" aria-expanded="true" data-type="folder">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon expanded"></span>
<span class="tree-icon folder-icon open"></span>
<span class="tree-label">Test Folder 23:31:47</span>
</div>
<ul role="group">
<li class="tree-item" role="treeitem" data-type="file">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon no-children"></span>
<span class="tree-icon file-icon"></span>
<span class="tree-label">Geiler Schyze</span>
</div>
</li>
<li class="tree-item" role="treeitem" data-type="file">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon no-children"></span>
<span class="tree-icon file-icon"></span>
<span class="tree-label">Ne Liste</span>
</div>
</li>
<li class="tree-item" role="treeitem" data-type="file">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon no-children"></span>
<span class="tree-icon file-icon"></span>
<span class="tree-label">Das ne Testlist</span>
</div>
</li>
<li class="tree-item" role="treeitem" data-type="file">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon no-children"></span>
<span class="tree-icon file-icon"></span>
<span class="tree-label">HHB</span>
</div>
</li>
<li class="tree-item" role="treeitem" data-type="file">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon no-children"></span>
<span class="tree-icon file-icon"></span>
<span class="tree-label">Ab ins</span>
</div>
</li>
<li class="tree-item" role="treeitem" data-type="file">
<div class="tree-item-content" tabindex="-1">
<span class="expand-icon no-children"></span>
<span class="tree-icon file-icon"></span>
<span class="tree-label">Test</span>
</div>
</li>
</ul>
</li>
</ul>
</nav>
</div>
<div class="instructions">
<h3>Keyboard Navigation:</h3>
<ul>
<li><kbd>↑</kbd> <kbd>↓</kbd> - Navigate between items</li>
<li><kbd>←</kbd> - Collapse folder or move to parent</li>
<li><kbd>→</kbd> - Expand folder or move to first child</li>
<li><kbd>Enter</kbd> or <kbd>Space</kbd> - Select item / Toggle folder</li>
<li><kbd>Home</kbd> - Jump to first item</li>
<li><kbd>End</kbd> - Jump to last item</li>
</ul>
</div>
<script>
class TreeNavigation {
constructor(treeElement) {
this.tree = treeElement;
this.items = [];
this.currentIndex = 0;
this.init();
}
init() {
this.updateItemsList();
this.attachEventListeners();
// Set initial focus
if (this.items.length > 0) {
this.setFocus(0);
}
}
updateItemsList() {
// Get all visible tree items
this.items = Array.from(this.tree.querySelectorAll('.tree-item-content'))
.filter(item => {
// Check if item is visible (not in collapsed parent)
let parent = item.parentElement;
while (parent && parent !== this.tree) {
if (parent.classList.contains('collapsed')) {
return false;
}
parent = parent.parentElement;
}
return true;
});
}
attachEventListeners() {
// Keyboard navigation
this.tree.addEventListener('keydown', (e) => this.handleKeyDown(e));
// Mouse click
this.tree.addEventListener('click', (e) => {
const itemContent = e.target.closest('.tree-item-content');
if (itemContent) {
const index = this.items.indexOf(itemContent);
if (index !== -1) {
this.setFocus(index);
this.toggleItem(itemContent);
}
}
});
}
handleKeyDown(e) {
const item = e.target.closest('.tree-item-content');
if (!item) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
this.focusNext();
break;
case 'ArrowUp':
e.preventDefault();
this.focusPrevious();
break;
case 'ArrowRight':
e.preventDefault();
this.expandOrMoveToChild(item);
break;
case 'ArrowLeft':
e.preventDefault();
this.collapseOrMoveToParent(item);
break;
case 'Enter':
case ' ':
e.preventDefault();
this.toggleItem(item);
break;
case 'Home':
e.preventDefault();
this.setFocus(0);
break;
case 'End':
e.preventDefault();
this.setFocus(this.items.length - 1);
break;
}
}
focusNext() {
if (this.currentIndex < this.items.length - 1) {
this.setFocus(this.currentIndex + 1);
}
}
focusPrevious() {
if (this.currentIndex > 0) {
this.setFocus(this.currentIndex - 1);
}
}
setFocus(index) {
// Remove tabindex from all items
this.items.forEach(item => item.setAttribute('tabindex', '-1'));
// Set tabindex and focus on current item
if (this.items[index]) {
this.currentIndex = index;
this.items[index].setAttribute('tabindex', '0');
this.items[index].focus();
}
}
expandOrMoveToChild(item) {
const treeItem = item.parentElement;
const isFolder = treeItem.getAttribute('data-type') === 'folder';
if (isFolder) {
const isExpanded = treeItem.getAttribute('aria-expanded') === 'true';
if (!isExpanded) {
this.expandFolder(treeItem);
} else {
// Move to first child if expanded
const firstChild = treeItem.querySelector('ul > li > .tree-item-content');
if (firstChild) {
this.updateItemsList();
const index = this.items.indexOf(firstChild);
if (index !== -1) {
this.setFocus(index);
}
}
}
}
}
collapseOrMoveToParent(item) {
const treeItem = item.parentElement;
const isFolder = treeItem.getAttribute('data-type') === 'folder';
const isExpanded = treeItem.getAttribute('aria-expanded') === 'true';
if (isFolder && isExpanded) {
this.collapseFolder(treeItem);
} else {
// Move to parent folder
const parentGroup = treeItem.parentElement.closest('li[role="treeitem"]');
if (parentGroup) {
const parentContent = parentGroup.querySelector('.tree-item-content');
const index = this.items.indexOf(parentContent);
if (index !== -1) {
this.setFocus(index);
}
}
}
}
toggleItem(item) {
const treeItem = item.parentElement;
const isFolder = treeItem.getAttribute('data-type') === 'folder';
// Remove selected class from all items
this.items.forEach(i => i.classList.remove('selected'));
// Add selected class to current item
item.classList.add('selected');
if (isFolder) {
const isExpanded = treeItem.getAttribute('aria-expanded') === 'true';
if (isExpanded) {
this.collapseFolder(treeItem);
} else {
this.expandFolder(treeItem);
}
} else {
// File selected - you can add custom behavior here
console.log('Selected file:', item.querySelector('.tree-label').textContent);
}
}
expandFolder(treeItem) {
treeItem.setAttribute('aria-expanded', 'true');
const group = treeItem.querySelector('ul[role="group"]');
const expandIcon = treeItem.querySelector('.expand-icon');
const folderIcon = treeItem.querySelector('.folder-icon');
if (group) {
group.classList.remove('collapsed');
}
if (expandIcon) {
expandIcon.classList.add('expanded');
}
if (folderIcon) {
folderIcon.classList.add('open');
}
this.updateItemsList();
}
collapseFolder(treeItem) {
treeItem.setAttribute('aria-expanded', 'false');
const group = treeItem.querySelector('ul[role="group"]');
const expandIcon = treeItem.querySelector('.expand-icon');
const folderIcon = treeItem.querySelector('.folder-icon');
if (group) {
group.classList.add('collapsed');
}
if (expandIcon) {
expandIcon.classList.remove('expanded');
}
if (folderIcon) {
folderIcon.classList.remove('open');
}
this.updateItemsList();
}
}
// Initialize the tree navigation
document.addEventListener('DOMContentLoaded', () => {
const tree = document.getElementById('fileTree');
new TreeNavigation(tree);
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment