Skip to the solution
MediumMachine Coding12 min read

Build a File Explorer Toolbar — Breadcrumbs, Search & Context Menu

1.2k views

The problem

Question presented to candidate: "You already have a working file tree. Now add the toolbar around it: breadcrumbs that show where the selected item lives and let me jump to any parent folder, a search box that filters the tree but keeps the folders that lead to each match, and a right-click context menu with New file, New folder, Rename and Delete."

Requirements:

  • Breadcrumbs for the current folder (the selected folder, or the parent of the selected file); every crumb except the last is a button that navigates there; the last one has aria-current="page".
  • Search filters the tree, keeps every ancestor of a match, opens those folders while searching, highlights the matched text and shows a match count or a "No files match" message. Clearing the search restores the folders the user had open.
  • Right-click on a row opens a menu at the pointer, kept inside the viewport, rendered outside the tree container (portal / Teleport / moved to body). Shift+F10 or the ContextMenu key opens it from the keyboard.
  • Menu keyboard: focus the first item, Up/Down wrap, Home/End, Escape closes and returns focus to the row; a press outside, a resize or a scroll closes it.
  • Actions: New file / New folder (unique default names) in the target folder, Rename inline with a duplicate check, Delete.

What a strong answer should cover:

  • Deriving the path with a parentOf map built in one walk, instead of storing the path.
  • A filter that returns a pruned copy of the tree plus the set of folders to force open, without touching the user's own expanded set.
  • Why the menu is portaled, how it is positioned and clamped, and how outside-click and Escape are wired and cleaned up.
  • The menu's ARIA roles (menu, menuitem), roving focus and focus return.
  • Acting on the real node by id, not on the filtered copy that was rendered.

Clarifying questions expected:

  • "Should search match only names, or full paths too? Case-sensitive?"
  • "Does selecting a search result clear the search, or keep the filtered view?"
  • "Are there actions that depend on the target, such as no Rename for the project root?"

Code / implementation expected: Yes. Build on a simple recursive tree; the toolbar features are the focus.

  • React
  • File Explorer
  • Context Menu
  • Breadcrumbs

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

12 min read · 9 sections

React solution

Every file of the working solution. Open it in the playground to run and change it.

ReactComplete solution
import { useEffect, useMemo, useRef, useState } from "react";
import TreeItem from "./src/components/TreeItem";
import ContextMenu, { placeMenu } from "./src/components/ContextMenu";
import {
  initialTree,
  makeId,
  sortNodes,
  indexTree,
  pathTo,
  filterTree,
  countMatches,
  updateNode,
  removeNode,
  insertNode,
  uniqueName,
} from "./src/utils/tree";

export default function App() {
  const [tree, setTree] = useState(initialTree);
  const [expanded, setExpanded] = useState(() => new Set(["src"]));
  const [selectedId, setSelectedId] = useState(null);
  const [query, setQuery] = useState("");
  const [menu, setMenu] = useState(null); // { left, top, nodeId }
  const [renamingId, setRenamingId] = useState(null);
  const [renameError, setRenameError] = useState("");
  const [status, setStatus] = useState("");
  const treeRef = useRef(null);
  const focusPending = useRef(null);

  // Everything below is derived from state
  // on each render; nothing is duplicated.
  const { byId, parentOf } = useMemo(() => indexTree(tree), [tree]);
  const q = query.trim().toLowerCase();
  const view = useMemo(() => {
    if (!q) return { nodes: tree, open: expanded, matches: null };
    const open = new Set();
    return {
      nodes: filterTree(tree, q, open),
      open,
      matches: countMatches(tree, q),
    };
  }, [tree, expanded, q]);
  const selected = byId.get(selectedId) ?? null;
  const folderId = !selected
    ? null
    : selected.type === "folder"
      ? selected.id
      : parentOf.get(selected.id);
  const crumbs = folderId ? pathTo(folderId, byId, parentOf) : [];
  const visible = sortNodes(view.nodes);
  const visibleIds = useMemo(() => {
    const ids = new Set();
    const walk = (ns) =>
      ns.forEach((n) => {
        ids.add(n.id);
        if (n.children && view.open.has(n.id)) walk(n.children);
      });
    walk(view.nodes);
    return ids;
  }, [view]);
  const tabStopId = visibleIds.has(selectedId) ? selectedId : visible[0]?.id;

  useEffect(() => {
    if (!focusPending.current) return;
    treeRef.current
      .querySelector(`[data-id="${focusPending.current}"]`)
      ?.focus();
    focusPending.current = null;
  });

  // Open every ancestor of id (and id itself when asked), so it stays visible
  // after the search is cleared.
  function reveal(id, includeSelf) {
    setExpanded((prev) => {
      const next = new Set(prev);
      for (
        let cur = includeSelf ? id : parentOf.get(id);
        cur != null;
        cur = parentOf.get(cur)
      )
        next.add(cur);
      return next;
    });
  }

  function navigate(id) {
    // breadcrumb click
    setSelectedId(id);
    if (id) reveal(id, true);
    setQuery("");
  }

  function onRowClick(node) {
    setSelectedId(node.id);
    reveal(node.id, false);
    if (node.type === "folder" && !q) {
      setExpanded((prev) => {
        const next = new Set(prev);
        if (next.has(node.id)) next.delete(node.id);
        else next.add(node.id);
        return next;
      });
    }
  }

  const MENU_ITEMS = 4;
  function openMenu(e, node) {
    e.preventDefault(); // suppress the browser menu
    setSelectedId(node.id);
    setMenu({
      ...placeMenu(e.clientX, e.clientY, MENU_ITEMS),
      nodeId: node.id,
    });
  }

  function onRowKey(e, node) {
    if (e.key === "ContextMenu" || (e.shiftKey && e.key === "F10")) {
      e.preventDefault();
      const r = e.currentTarget.getBoundingClientRect();
      setSelectedId(node.id);
      setMenu({
        ...placeMenu(r.left + 24, r.bottom, MENU_ITEMS),
        nodeId: node.id,
      });
    }
  }

  function closeMenu(returnFocus) {
    if (returnFocus && menu) focusPending.current = menu.nodeId;
    setMenu(null);
  }

  function create(node, type) {
    const parentId = node.type === "folder" ? node.id : parentOf.get(node.id);
    const siblings = parentId === null ? tree : byId.get(parentId).children;
    const item = { id: makeId(), name: uniqueName(siblings, type), type };
    if (type === "folder") item.children = [];
    setTree((t) => insertNode(t, parentId, item));
    if (parentId) reveal(parentId, true);
    setSelectedId(item.id);
    focusPending.current = item.id;
    setStatus(
      "Created " +
        item.name +
        " in " +
        (parentId ? byId.get(parentId).name : "Project"),
    );
  }

  function remove(node) {
    const parentId = parentOf.get(node.id);
    setTree((t) => removeNode(t, node.id));
    setSelectedId(parentId);
    focusPending.current = parentId;
    setStatus("Deleted " + node.name);
  }

  function commitRename(raw) {
    const name = raw.trim();
    const node = byId.get(renamingId);
    if (!name || name === node.name) {
      cancelRename();
      return;
    }
    const parentId = parentOf.get(node.id);
    const siblings = parentId === null ? tree : byId.get(parentId).children;
    if (
      siblings.some(
        (s) => s.id !== node.id && s.name.toLowerCase() === name.toLowerCase(),
      )
    ) {
      setRenameError('"' + name + '" already exists here');
      return;
    }
    setTree((t) => updateNode(t, node.id, (n) => ({ ...n, name })));
    setStatus("Renamed " + node.name + " to " + name);
    cancelRename();
  }

  function cancelRename() {
    focusPending.current = renamingId;
    setRenamingId(null);
    setRenameError("");
  }

  const target = menu ? byId.get(menu.nodeId) : null;
  const menuItems = target
    ? [
        { label: "New file", run: () => create(target, "file") },
        { label: "New folder", run: () => create(target, "folder") },
        {
          label: "Rename",
          run: () => {
            setRenamingId(target.id);
            setRenameError("");
          },
        },
        { label: "Delete", danger: true, run: () => remove(target) },
      ]
    : [];

  const api = {
    query: q,
    selectedId,
    tabStopId,
    renamingId,
    renameError,
    isOpen: (id) => view.open.has(id),
    onRowClick,
    onRowKey,
    openMenu,
    commitRename,
    cancelRename,
  };

  return (
    <div className="app">
      <nav aria-label="Breadcrumb" className="crumbs" data-testid="breadcrumbs">
        <ol>
          <li>
            {crumbs.length ? (
              <button onClick={() => navigate(null)}>Project</button>
            ) : (
              <span aria-current="page">Project</span>
            )}
          </li>
          {crumbs.map((c, i) => (
            <li key={c.id}>
              <span className="sep" aria-hidden="true">
                /
              </span>
              {i === crumbs.length - 1 ? (
                <span aria-current="page">{c.name}</span>
              ) : (
                <button onClick={() => navigate(c.id)}>{c.name}</button>
              )}
            </li>
          ))}
        </ol>
      </nav>

      <input
        type="search"
        className="search"
        data-testid="search"
        placeholder="Filter files..."
        aria-label="Filter files"
        aria-controls="file-tree"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      <p className="summary" aria-live="polite" data-testid="summary">
        {view.matches === null
          ? ""
          : view.matches
            ? view.matches + " match" + (view.matches === 1 ? "" : "es")
            : 'No files match "' + query.trim() + '"'}
      </p>

      <ul
        id="file-tree"
        role="tree"
        aria-label="Project files"
        className="tree"
        ref={treeRef}
      >
        {visible.map((n) => (
          <TreeItem key={n.id} node={n} depth={1} api={api} />
        ))}
      </ul>
      <p className="status" role="status" data-testid="status">
        {status}
      </p>

      {menu && target && (
        <ContextMenu
          left={menu.left}
          top={menu.top}
          label={"Actions for " + target.name}
          items={menuItems}
          onClose={closeMenu}
        />
      )}
    </div>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 26 of 107 in Machine coding

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

Build a Color Picker

Continue