File Explorer

Nested tree with folders and files.

  • src
    • index.ts
    • globals.css
  • package.json
  • README.md
<ul class="tree" role="tree" aria-label="File explorer">
  <li class="tree-item" role="treeitem" aria-expanded="true">
    <details class="tree-branch" open>
      <summary class="tree-branch-trigger">
        <svg>...chevron...</svg>
        <svg>...folder...</svg>
        <span>src</span>
      </summary>
      <ul class="tree-group" role="group">
        <li class="tree-item" role="treeitem">
          <span class="tree-leaf">
            <svg>...file...</svg>
            <span>index.ts</span>
          </span>
        </li>
      </ul>
    </details>
  </li>
</ul>

CSS view file

Styles for the tree-view component. Uses design tokens for colors, spacing, and radius.

@layer components {
  .tree {
    list-style: none;
    margin: 0;
    padding: 0;
    font-size: 0.875rem;
  }

  .tree-group {
    list-style: none;
    margin: 0;
    padding: 0 0 0 1.25rem;
  }

  .tree-item {
    padding: 0;
  }

  .tree-branch {
    border: none;

    & ::details-content {
      block-size: 0;
      overflow-y: clip;
      transition: block-size 200ms ease, content-visibility 200ms allow-discrete;
    }

    &[open] ::details-content {
      block-size: auto;
    }
  }

  @starting-style {
    .tree-branch[open] ::details-content {
      block-size: 0;
    }
  }

  .tree-branch-trigger {
    display: flex;
    align-items: center;
    gap: 0.375rem;
    padding: 0.25rem 0.5rem;
    border-radius: var(--radius-md);
    cursor: pointer;
    color: var(--foreground);
    list-style: none;

    &:hover {
      background-color: var(--accent);
    }

    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: -2px;
    }

    &::-webkit-details-marker {
      display: none;
    }

    & svg {
      width: 1rem;
      height: 1rem;
      flex-shrink: 0;
    }

    & svg:first-child {
      width: 0.875rem;
      height: 0.875rem;
      color: var(--muted-foreground);
      transition: transform 200ms ease;
    }
  }

  details[open] > .tree-branch-trigger > svg:first-child {
    transform: rotate(90deg);
  }

  .tree-leaf {
    display: flex;
    align-items: center;
    gap: 0.375rem;
    padding: 0.25rem 0.5rem;
    padding-left: calc(0.5rem + 0.875rem + 0.375rem);
    border-radius: var(--radius-md);
    color: var(--foreground);
    cursor: default;

    &:hover {
      background-color: var(--accent);
    }

    & svg {
      width: 1rem;
      height: 1rem;
      flex-shrink: 0;
      color: var(--muted-foreground);
    }
  }
}

JavaScript view file

Interaction logic for the tree-view component. Uses data attributes for wiring.

// -- Tree View ------------------------------------------------
// Keyboard navigation and ARIA state for tree views.

function init() {
  document.querySelectorAll('.tree[role="tree"]:not([data-init])').forEach((tree) => {
    tree.dataset.init = '';
    /* Keep aria-expanded in sync with <details> open state */
    tree.querySelectorAll('.tree-branch').forEach((details) => {
      const treeitem = details.closest('[role="treeitem"]');
      if (!treeitem) return;

      details.addEventListener('toggle', () => {
        treeitem.setAttribute('aria-expanded', String(details.open));
      });
    });

    /* Keyboard navigation */
    tree.addEventListener('keydown', (e) => {
      const target = e.target.closest('.tree-branch-trigger, .tree-leaf');
      if (!target) return;

      const allItems = Array.from(tree.querySelectorAll('.tree-branch-trigger, .tree-leaf'));
      const visibleItems = allItems.filter((item) => item.checkVisibility());
      const index = visibleItems.indexOf(target);

      switch (e.key) {
        case 'ArrowDown':
          e.preventDefault();
          if (index < visibleItems.length - 1) visibleItems[index + 1].focus();
          break;
        case 'ArrowUp':
          e.preventDefault();
          if (index > 0) visibleItems[index - 1].focus();
          break;
        case 'ArrowRight':
          e.preventDefault();
          { const detailsR = target.closest('details.tree-branch');
          if (detailsR && !detailsR.open) detailsR.open = true; }
          break;
        case 'ArrowLeft':
          e.preventDefault();
          { const detailsL = target.closest('details.tree-branch');
          if (detailsL && detailsL.open) detailsL.open = false; }
          break;
        case 'Home':
          e.preventDefault();
          if (visibleItems.length) visibleItems[0].focus();
          break;
        case 'End':
          e.preventDefault();
          if (visibleItems.length) visibleItems[visibleItems.length - 1].focus();
          break;
      }
    });
});
}

init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });