Domphy

fileTree

A Community block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call fileTree() with no arguments for a working demo, or edit the code below live.

Implementation notes

Full recursive tree: folder rows toggle via a CSS grid 0fr/1fr accordion track (animates any content height without JS measurement, avoids <details>'s UA display which can't smoothly transition), a rotating chevron, and a closed/open icon swap (both custom hand-drawn generic folder/document SVG glyphs, not any real icon library's artwork). File rows select with a persistent aria-selected highlight. Supports controlled/pre-set expandedIds and selectedId (plain array/id or a Domphy State for external control), per-node selectable flag, folders-first/as-is/custom-comparator sort modes, custom renderFolderIcon/renderFileIcon callbacks, onSelect/onToggle callbacks, and ltr/rtl via the dir attribute + logical padding properties. One deliberate deviation from a literal prop-default reading: passing custom data without selectedId/expandedIds starts with nothing selected/expanded rather than reusing the built-in demo's ids, which would otherwise silently no-op against unrelated data.

Status: ported · Reference: Magic UI original

// shadcn-community "File Tree" — clean-room reimplementation.
//
// A nested, expandable directory/file browser resembling a code editor's
// sidebar explorer. Implemented purely from the block's public functional/
// visual spec — no upstream source was viewed or copied.
//
// Structurally close to a Collapsible/Accordion-built tree view, but built
// fully custom here (rather than layered on the `accordion()`/`details()`
// patches) so expand state, selection, sort order, and custom icon
// renderers can all be driven from one shared, optionally externally
// controlled reactive context — matching the props surface the spec asks
// for (pre-set/controlled expanded ids + selected id, onSelect/onToggle).
//
// The expand/collapse reveal uses the CSS grid "0fr → 1fr" accordion trick
// (a `display: grid` wrapper whose single track animates between those two
// sizes) rather than `<details>`'s native open/close, which the UA
// stylesheet hides via `display: none` when closed — a property transitions
// can't smoothly animate across without extra opt-in machinery. The grid
// trick needs no height measurement and works for any content height.

import type {
  DomphyElement,
  Listener,
  State,
  StyleObject,
  ValueOrState,
} from "@domphy/core";
import { toState } from "@domphy/core";
import { small } from "@domphy/ui";
import {
  type ThemeColor,
  themeColor,
  themeDensity,
  themeSize,
  themeSpacing,
} from "@domphy/theme";

export interface FileTreeNode {
  /** Stable identifier — also the value passed to `onSelect`/`onToggle` and used for `_key`. */
  id: string;
  /** Displayed label. */
  name: string;
  type: "file" | "folder";
  /** Nested entries. Only meaningful when `type` is `"folder"`. */
  children?: FileTreeNode[];
  /** Whether this node can become the selected item. Files default to `true`; folders ignore this (they toggle, not select). */
  selectable?: boolean;
}

export type FileTreeSortMode =
  | "folders-first"
  | "as-is"
  | ((a: FileTreeNode, b: FileTreeNode) => number);

export interface FileTreeProps {
  /** Root-level nodes. Defaults to a small demo `src/` layout. */
  data?: FileTreeNode[];
  /** Folder ids that start (or, when a `State` is passed, stay) expanded. */
  expandedIds?: ValueOrState<string[]>;
  /** The initially (or, when a `State` is passed, externally) selected node id. */
  selectedId?: ValueOrState<string | null>;
  /** How sibling nodes at each level are ordered. Defaults to `"folders-first"`. */
  sort?: FileTreeSortMode;
  /** Text direction. Defaults to `"ltr"`. */
  direction?: "ltr" | "rtl";
  /** Custom closed/open folder icon renderer. Defaults to a generic folder glyph. */
  renderFolderIcon?: (open: boolean, node: FileTreeNode) => DomphyElement;
  /** Custom file icon renderer (e.g. per extension). Defaults to a generic document glyph. */
  renderFileIcon?: (node: FileTreeNode) => DomphyElement;
  onSelect?: (node: FileTreeNode) => void;
  onToggle?: (node: FileTreeNode, open: boolean) => void;
  /** Theme color for the panel surface/borders. Defaults to `"neutral"`. */
  color?: ThemeColor;
  /** Accent color for the selected-row highlight. Defaults to `"primary"`. */
  accentColor?: ThemeColor;
  style?: StyleObject;
}

interface FileTreeContext {
  selectedId: State<string | null>;
  expandedIds: State<string[]>;
  sort: FileTreeSortMode;
  color: ThemeColor;
  accentColor: ThemeColor;
  renderFolderIcon: (open: boolean, node: FileTreeNode) => DomphyElement;
  renderFileIcon: (node: FileTreeNode) => DomphyElement;
  onSelect?: (node: FileTreeNode) => void;
  onToggle?: (node: FileTreeNode, open: boolean) => void;
}

const DEFAULT_DATA: FileTreeNode[] = [
  {
    id: "src",
    name: "src",
    type: "folder",
    children: [
      {
        id: "src/components",
        name: "components",
        type: "folder",
        children: [
          { id: "src/components/Button.tsx", name: "Button.tsx", type: "file" },
          { id: "src/components/Card.tsx", name: "Card.tsx", type: "file" },
        ],
      },
      { id: "src/app.ts", name: "app.ts", type: "file" },
      { id: "src/index.ts", name: "index.ts", type: "file" },
    ],
  },
  { id: "package.json", name: "package.json", type: "file" },
  { id: "readme", name: "README.md", type: "file" },
];

const DEFAULT_EXPANDED_IDS = ["src"];
const DEFAULT_SELECTED_ID = "src/index.ts";
const ROW_INDENT_STEP = 5;
const ROW_BASE_INDENT = 2;

function sortNodes(nodes: FileTreeNode[], sort: FileTreeSortMode): FileTreeNode[] {
  if (sort === "as-is") return nodes;
  if (typeof sort === "function") return [...nodes].sort(sort);
  return [...nodes].sort((a, b) => {
    if (a.type !== b.type) return a.type === "folder" ? -1 : 1;
    return a.name.localeCompare(b.name);
  });
}

function isNodeExpanded(context: FileTreeContext, node: FileTreeNode, listener: Listener): boolean {
  return context.expandedIds.get(listener).includes(node.id);
}

function rowBaseStyle(depth: number, color: ThemeColor): StyleObject {
  return {
    display: "flex",
    alignItems: "center",
    gap: themeSpacing(1.5),
    height: themeSpacing(7),
    paddingInlineStart: themeSpacing(ROW_BASE_INDENT + depth * ROW_INDENT_STEP),
    paddingInlineEnd: themeSpacing(2),
    borderRadius: themeSpacing(1.5),
    userSelect: "none",
    fontSize: (listener: Listener) => themeSize(listener, "inherit"),
    color: (listener: Listener) => themeColor(listener, "shift-9", color),
    backgroundColor: "transparent",
    transition: "background-color 150ms ease",
    "&:hover": {
      backgroundColor: (listener: Listener) => themeColor(listener, "shift-2", color),
    },
  } as StyleObject;
}

function treeGlyph(paths: string[]): DomphyElement<"span"> {
  return {
    span: [
      {
        svg: paths.map((d, index) => ({ path: null, d, _key: `p-${index}` }) as DomphyElement),
        viewBox: "0 0 24 24",
        fill: "none",
        stroke: "currentColor",
        strokeWidth: "1.6",
        strokeLinecap: "round",
        strokeLinejoin: "round",
        role: "img",
        ariaHidden: "true",
        style: { width: "100%", height: "100%" },
      } as DomphyElement<"svg">,
    ],
    ariaHidden: "true",
    style: {
      display: "inline-flex",
      flexShrink: 0,
      width: themeSpacing(4.5),
      height: themeSpacing(4.5),
    },
  };
}

function defaultFolderIcon(open: boolean): DomphyElement {
  return open
    ? treeGlyph(["M3 7.2a1.5 1.5 0 0 1 1.5-1.5h4.2l1.8 1.9h8.6a1.5 1.5 0 0 1 1.47 1.8l-1.15 6.6A1.7 1.7 0 0 1 17.75 17.5H5.6a1.5 1.5 0 0 1-1.48-1.24L3 7.2Z"])
    : treeGlyph(["M3 6.5A1.5 1.5 0 0 1 4.5 5h4.3l1.8 1.9h9A1.5 1.5 0 0 1 21 8.4v8.1A1.5 1.5 0 0 1 19.5 18h-15A1.5 1.5 0 0 1 3 16.5v-10Z"]);
}

function defaultFileIcon(): DomphyElement {
  return treeGlyph([
    "M6.5 3.2h6.8l4 4v13.1a1 1 0 0 1-1 1h-9.8a1 1 0 0 1-1-1V4.2a1 1 0 0 1 1-1Z",
    "M13.3 3.4v3.9a1 1 0 0 0 1 1h3.9",
  ]);
}

function chevronGlyph(context: FileTreeContext, node: FileTreeNode): DomphyElement<"span"> {
  return {
    span: [
      {
        svg: [{ polyline: null, points: "9 5 15 12 9 19" }],
        viewBox: "0 0 24 24",
        fill: "none",
        stroke: "currentColor",
        strokeWidth: "2",
        strokeLinecap: "round",
        strokeLinejoin: "round",
        role: "img",
        ariaHidden: "true",
        style: { width: "100%", height: "100%" },
      } as DomphyElement<"svg">,
    ],
    ariaHidden: "true",
    style: {
      display: "inline-flex",
      flexShrink: 0,
      width: themeSpacing(3.5),
      height: themeSpacing(3.5),
      transition: "transform 200ms ease-out",
      transform: (listener: Listener) =>
        isNodeExpanded(context, node, listener) ? "rotate(90deg)" : "rotate(0deg)",
    },
  };
}

function buildFolderNode(node: FileTreeNode, depth: number, context: FileTreeContext): DomphyElement {
  const toggle = () => {
    const currentIds = context.expandedIds.get();
    const currentlyOpen = currentIds.includes(node.id);
    const next = !currentlyOpen;
    context.expandedIds.set(
      next ? [...currentIds, node.id] : currentIds.filter((id) => id !== node.id),
    );
    context.onToggle?.(node, next);
  };

  const sortedChildren = sortNodes(node.children ?? [], context.sort);

  const closedIcon: DomphyElement = {
    span: [context.renderFolderIcon(false, node)],
    style: {
      display: (listener: Listener) =>
        isNodeExpanded(context, node, listener) ? "none" : "inline-flex",
    },
  };
  const openIcon: DomphyElement = {
    span: [context.renderFolderIcon(true, node)],
    style: {
      display: (listener: Listener) =>
        isNodeExpanded(context, node, listener) ? "inline-flex" : "none",
    },
  };

  const header: DomphyElement = {
    div: [
      chevronGlyph(context, node),
      closedIcon,
      openIcon,
      {
        small: node.name,
        $: [small({ color: context.color })],
        style: {
          overflow: "hidden",
          textOverflow: "ellipsis",
          whiteSpace: "nowrap",
          flex: "1 1 auto",
          minWidth: 0,
        },
      },
    ],
    role: "treeitem",
    tabindex: 0,
    ariaExpanded: (listener: Listener) => String(isNodeExpanded(context, node, listener)),
    onClick: toggle,
    onKeyDown: (event: KeyboardEvent) => {
      if (event.key === "Enter" || event.key === " ") {
        event.preventDefault();
        toggle();
      }
    },
    style: { ...rowBaseStyle(depth, context.color), cursor: "pointer" } as StyleObject,
  } as DomphyElement;

  const childrenWrapper: DomphyElement = {
    div: [
      {
        div: sortedChildren.map((child) => buildNode(child, depth + 1, context)),
        style: { minHeight: 0 },
      },
    ],
    role: "group",
    style: {
      display: "grid",
      gridTemplateRows: (listener: Listener) =>
        isNodeExpanded(context, node, listener) ? "1fr" : "0fr",
      opacity: (listener: Listener) => (isNodeExpanded(context, node, listener) ? 1 : 0),
      overflow: "hidden",
      transition: "grid-template-rows 200ms ease-out, opacity 150ms ease-out",
    } as StyleObject,
  };

  return {
    div: [header, childrenWrapper],
    _key: node.id,
  };
}

function buildFileNode(node: FileTreeNode, depth: number, context: FileTreeContext): DomphyElement {
  const selectable = node.selectable ?? true;
  const select = () => {
    context.selectedId.set(node.id);
    context.onSelect?.(node);
  };

  const fileRow: Record<string, unknown> = {
    div: [
      // Spacer matching the folder row's chevron width, so file/folder names align.
      { span: null, ariaHidden: "true", style: { display: "inline-flex", flexShrink: 0, width: themeSpacing(3.5) } },
      context.renderFileIcon(node),
      {
        small: node.name,
        $: [small({ color: context.color })],
        style: {
          overflow: "hidden",
          textOverflow: "ellipsis",
          whiteSpace: "nowrap",
          flex: "1 1 auto",
          minWidth: 0,
        },
      },
    ],
    role: "treeitem",
    tabindex: selectable ? 0 : -1,
    ariaSelected: (listener: Listener) => String(context.selectedId.get(listener) === node.id),
    ariaDisabled: selectable ? "false" : "true",
    _key: node.id,
    style: {
      ...rowBaseStyle(depth, context.color),
      cursor: selectable ? "pointer" : "default",
      "&[aria-selected=true]": {
        backgroundColor: (listener: Listener) => themeColor(listener, "shift-3", context.accentColor),
        color: (listener: Listener) => themeColor(listener, "shift-11", context.accentColor),
      },
    } as StyleObject,
  };

  // Domphy's event validation rejects an explicit `onClick: undefined`
  // (unlike ordinary attribute props), so only attach handlers when the
  // node actually supports selection.
  if (selectable) {
    fileRow.onClick = select;
    fileRow.onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Enter" || event.key === " ") {
        event.preventDefault();
        select();
      }
    };
  }

  return fileRow as DomphyElement;
}

function buildNode(node: FileTreeNode, depth: number, context: FileTreeContext): DomphyElement {
  return node.type === "folder"
    ? buildFolderNode(node, depth, context)
    : buildFileNode(node, depth, context);
}

/**
 * A nested, expandable file/folder browser with accordion-style reveal,
 * a rotating chevron, an open/closed folder icon swap, and click-to-select
 * files. Call with no arguments for a working demo — a small `src/` layout
 * with one folder pre-expanded and `index.ts` pre-selected.
 */
function fileTree(props: FileTreeProps = {}): DomphyElement<"div"> {
  // The demo `selectedId`/`expandedIds` only make sense paired with the demo
  // `data` — a caller supplying their own `data` but no selection/expansion
  // prop should start with nothing selected/expanded, not the demo's ids.
  const usingDefaultData = props.data === undefined;
  const data = props.data ?? DEFAULT_DATA;
  const sort = props.sort ?? "folders-first";
  const direction = props.direction ?? "ltr";
  const color = props.color ?? "neutral";
  const accentColor = props.accentColor ?? "primary";

  const context: FileTreeContext = {
    selectedId: toState<string | null>(
      props.selectedId ?? (usingDefaultData ? DEFAULT_SELECTED_ID : null),
    ),
    expandedIds: toState<string[]>(
      props.expandedIds ?? (usingDefaultData ? DEFAULT_EXPANDED_IDS : []),
    ),
    sort,
    color,
    accentColor,
    renderFolderIcon: props.renderFolderIcon ?? defaultFolderIcon,
    renderFileIcon: props.renderFileIcon ?? defaultFileIcon,
    onSelect: props.onSelect,
    onToggle: props.onToggle,
  };

  const sortedRoots = sortNodes(data, sort);

  return {
    div: sortedRoots.map((node) => buildNode(node, 0, context)),
    dir: direction,
    role: "tree",
    style: {
      display: "flex",
      flexDirection: "column",
      gap: themeSpacing(0.5),
      padding: themeSpacing(2),
      borderRadius: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit", color),
      color: (listener: Listener) => themeColor(listener, "shift-9", color),
      outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-4", color)}`,
      outlineOffset: "-1px",
      fontSize: (listener: Listener) => themeSize(listener, "inherit"),
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { fileTree };

← Back to Magic UI catalog