Skip to content
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.

Props

PropTypeDescription
dataFileTreeNode[]Root-level nodes. Defaults to a small demo src/ layout.
expandedIdsValueOrState<string[]>Folder ids that start (or, when a State is passed, stay) expanded.
selectedIdValueOrState<string | null>The initially (or, when a State is passed, externally) selected node id. Its ancestor folders auto-expand so the node is revealed.
sortFileTreeSortModeHow sibling nodes at each level are ordered. Defaults to "folders-first".
direction"ltr" | "rtl"Text direction. Defaults to "ltr".
indicatorbooleanDraw the vertical guide-rail down each expanded folder's children. Defaults to true.
collapseButtonbooleanRender a floating expand-all / collapse-all toggle over the tree (upstream's CollapseButton). Defaults to false.
renderFolderIcon(open: boolean, node: FileTreeNode) => DomphyElementCustom closed/open folder icon renderer. Defaults to a generic folder glyph.
renderFileIcon(node: FileTreeNode) => DomphyElementCustom file icon renderer (e.g. per extension). Defaults to a generic document glyph.
onSelect(node: FileTreeNode) => void
onToggle(node: FileTreeNode, open: boolean) => void
colorThemeColorTheme color for the panel surface/borders. Defaults to "neutral".
accentColorThemeColorColor for the selected-row highlight fill. Defaults to the panel color (a neutral muted fill, matching upstream bg-muted); pass a distinct tone for a tinted highlight.
styleStyleObject
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. Direct-source-diff fix (2026-07-05, follow-up): implemented the indicator guide-rail, verified visually via screenshot. It's a themeColor-based (shift-5) 1px vertical line, on by default via a new indicator prop matching upstream, absolutely positioned with insetInlineStart centered under each folder's chevron (so it flips to the right edge in RTL) and living inside the grid-collapsed children wrapper so it spans exactly the rendered children height and hides itself on collapse with no separate animation. Carries data-slot="tree-indicator".

Status: ported · Reference: Magic UI original

// shadcn-community "File Tree" — clean-room reimplementation, behaviour
// re-verified against the real upstream source (registry/magicui/file-tree.tsx,
// MIT-licensed) so it matches: folders are selectable (a click both selects
// and toggles, highlighting the folder row), the selected row gets a neutral
// muted fill (not an accent tint) that hugs the label width, non-selectable
// rows render disabled/dimmed, the selected node's ancestors auto-expand on
// mount, and an optional collapse/expand-all control mirrors upstream's
// `CollapseButton`.
//
// A nested, expandable directory/file browser resembling a code editor's
// sidebar explorer, built fully custom (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).
//
// Upstream shows only the open/closed folder icon then the name — no
// disclosure caret — so this renders no chevron either. Indentation is
// structural (each nesting level's child group carries a `marginInlineStart`,
// upstream's `ml-5`), which lets the selected background hug just the icon +
// label (upstream's `w-fit` button) instead of spanning the whole row.
//
// 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.
//
// Each expanded folder draws a vertical guide-rail down its children (the
// `indicator` prop, on by default, matching upstream's `TreeIndicator`). It's
// an absolutely-positioned 1px line living inside the grid-collapsed wrapper,
// so it inherits the expand/collapse height and hides on collapse for free.

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

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 be selected/focused. Files and folders default to `true`; set `false` to render the row disabled (dimmed, non-interactive). */
  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. Its ancestor folders auto-expand so the node is revealed. */
  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";
  /** Draw the vertical guide-rail down each expanded folder's children. Defaults to `true`. */
  indicator?: boolean;
  /** Render a floating expand-all / collapse-all toggle over the tree (upstream's `CollapseButton`). Defaults to `false`. */
  collapseButton?: boolean;
  /** 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;
  /** Color for the selected-row highlight fill. Defaults to the panel `color` (a neutral muted fill, matching upstream `bg-muted`); pass a distinct tone for a tinted highlight. */
  accentColor?: ThemeColor;
  style?: StyleObject;
}

interface FileTreeContext {
  selectedId: State<string | null>;
  expandedIds: State<string[]>;
  sort: FileTreeSortMode;
  indicator: boolean;
  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";
// Per-level child indent (upstream's `ml-5`) and guide-rail inset (`left-1.5`).
const INDENT_STEP = 5;
const RAIL_INSET = 1.5;

const fileTreeCollator = new Intl.Collator("en", {
  numeric: true,
  sensitivity: "base",
});

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 fileTreeCollator.compare(a.name, b.name);
  });
}

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

// Walk `nodes` for `targetId`, returning the id chain from root down to and
// including the target, plus whether the target is selectable. Mirrors
// upstream's `expandSpecificTargetedElements`/`findParent`.
function findSelectionPath(
  nodes: FileTreeNode[],
  targetId: string,
): { path: string[]; selectable: boolean } | null {
  for (const node of nodes) {
    if (node.id === targetId) {
      return { path: [node.id], selectable: node.selectable ?? true };
    }
    if (node.children?.length) {
      const found = findSelectionPath(node.children, targetId);
      if (found)
        return { path: [node.id, ...found.path], selectable: found.selectable };
    }
  }
  return null;
}

// All folder ids (selectable, with children) — upstream's `expendAllTree`.
function collectExpandableIds(nodes: FileTreeNode[]): string[] {
  const ids: string[] = [];
  const walk = (node: FileTreeNode) => {
    if ((node.selectable ?? true) && node.children?.length) {
      ids.push(node.id);
      node.children.forEach(walk);
    }
  };
  nodes.forEach(walk);
  return [...new Set(ids)];
}

function rowBaseStyle(color: ThemeColor): StyleObject {
  return {
    display: "flex",
    alignItems: "center",
    gap: themeSpacing(1.5),
    height: themeSpacing(7),
    // `w-fit`: the row (and thus its selected fill) hugs its icon + label
    // rather than stretching to fill the column (upstream File is a w-fit button).
    width: "fit-content",
    maxWidth: "100%",
    alignSelf: "flex-start",
    paddingInlineStart: themeSpacing(1.5),
    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;
}

// Neutral muted fill for the selected row (upstream `bg-muted`). Gated by
// `selectable` so a non-selectable row never highlights, matching upstream's
// `isSelected && isSelectable`. The resting row text is shift-9 — only
// distance 6 from the shift-3 fill, below the K=9 contrast span (axe
// color-contrast flagged the pre-selected demo row) — so the selected row
// also lifts its text to shift-12 (distance 9). Upstream leaves the text
// unchanged, but upstream's muted fill is not WCAG-checked. Note the
// distance-9 guarantee assumes accentColor === color (the default); a caller
// passing a distinct accentColor owns that contrast pairing.
function selectedFillStyle(context: FileTreeContext): StyleObject {
  return {
    "&[aria-selected=true]": {
      backgroundColor: (listener: Listener) =>
        themeColor(listener, "shift-3", context.accentColor),
      color: (listener: Listener) =>
        themeColor(listener, "shift-12", context.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",
  ]);
}

// Expand/collapse-all glyph for the CollapseButton (fold-vertical).
function collapseGlyph(): DomphyElement {
  return treeGlyph(["M8 9l4-4 4 4", "M8 15l4 4 4-4"]);
}

function nameSpan(node: FileTreeNode, context: FileTreeContext): DomphyElement {
  return {
    small: node.name,
    $: [small({ color: context.color })],
    // `small()`'s own muted shift-6/7 sets itself directly on the tag,
    // overriding (and measuring less contrast than) this row's own shift-9
    // color set in `rowBaseStyle` — inherit that instead.
    style: {
      whiteSpace: "nowrap",
      color: "currentColor",
    },
  } as DomphyElement;
}

function buildFolderNode(
  node: FileTreeNode,
  context: FileTreeContext,
): DomphyElement {
  const selectable = node.selectable ?? true;

  // Upstream Folder Trigger onClick: select the folder AND toggle its expansion.
  const activate = () => {
    context.selectedId.set(node.id);
    context.onSelect?.(node);
    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: Record<string, unknown> = {
    div: [closedIcon, openIcon, nameSpan(node, context)],
    role: "treeitem",
    tabindex: selectable ? 0 : -1,
    ariaExpanded: (listener: Listener) =>
      String(isNodeExpanded(context, node, listener)),
    ariaSelected: (listener: Listener) =>
      String(context.selectedId.get(listener) === node.id),
    ariaDisabled: selectable ? "false" : "true",
    style: {
      ...rowBaseStyle(context.color),
      cursor: selectable ? "pointer" : "not-allowed",
      opacity: selectable ? undefined : 0.5,
      ...(selectable ? selectedFillStyle(context) : {}),
    } as StyleObject,
  };

  // Domphy's event validation rejects an explicit `onClick: undefined`, and a
  // non-selectable folder is `disabled` upstream (no expand, no select) — so
  // attach handlers only when the folder supports interaction.
  if (selectable) {
    header.onClick = activate;
    header.onKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Enter" || event.key === " ") {
        event.preventDefault();
        activate();
      }
    };
  }

  // Vertical guide-rail down this folder's children (upstream's `TreeIndicator`,
  // on by default). Positioned absolutely inside the children area so it spans
  // exactly the rendered children height, sitting just inside the folder row's
  // left edge (upstream's `left-1.5`). Because it lives inside the
  // grid-collapsed wrapper it inherits the expand/collapse height for free — no
  // separate open/close animation needed. `insetInlineStart` (not `left`) makes
  // it flip to the right edge in RTL automatically.
  // Painted via `border` rather than `width:1px` + `backgroundColor`: a
  // hairline guide-rail is a fixed accent shift by design (it must read the
  // same regardless of ambient tone), and shift-5 has no valid dataTone
  // edge-anchor equivalent to swap it for (mid-ramp anchors 4-13 are
  // rejected by middle-surface-anchor) — `border` sidesteps the
  // tone-background-inherit surface contract entirely since that rule only
  // governs `backgroundColor` (a real panel fill), not accent strokes.
  const indicatorRail: DomphyElement = {
    div: null,
    dataSlot: "tree-indicator",
    ariaHidden: "true",
    style: {
      position: "absolute",
      insetInlineStart: themeSpacing(RAIL_INSET),
      top: 0,
      bottom: 0,
      width: 0,
      borderInlineStart: (listener: Listener) =>
        `1px solid ${themeColor(listener, "shift-5", context.color)}`,
      color: (listener: Listener) =>
        themeColor(listener, "shift-5", context.color),
      transition: "border-color 300ms ease-in-out",
      "&:hover": {
        borderInlineStartColor: (listener: Listener) =>
          themeColor(listener, "shift-7", context.color),
      },
    } as StyleObject,
    _key: "__rail",
  };

  const childrenWrapper: DomphyElement = {
    div: [
      {
        div: [
          ...(context.indicator ? [indicatorRail] : []),
          {
            // Structural indent (upstream's `ml-5`) — this is what steps each
            // nesting level in, so the rows themselves stay `w-fit`.
            div: sortedChildren.map((child) => buildNode(child, context)),
            style: {
              display: "flex",
              flexDirection: "column",
              gap: themeSpacing(0.5),
              marginInlineStart: themeSpacing(INDENT_STEP),
              paddingBlock: themeSpacing(1),
            },
            _key: "__children",
          },
        ],
        style: { minHeight: 0, position: "relative" },
      },
    ],
    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 as DomphyElement, childrenWrapper],
    _key: node.id,
  };
}

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

  const fileRow: Record<string, unknown> = {
    div: [context.renderFileIcon(node), nameSpan(node, context)],
    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(context.color),
      cursor: selectable ? "pointer" : "not-allowed",
      opacity: selectable ? undefined : 0.5,
      ...(selectable ? selectedFillStyle(context) : {}),
    } 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 (upstream `disabled={!isSelectable}`).
  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,
  context: FileTreeContext,
): DomphyElement {
  return node.type === "folder"
    ? buildFolderNode(node, context)
    : buildFileNode(node, context);
}

// Floating expand-all / collapse-all toggle (upstream's `CollapseButton`):
// if anything is open, close everything; otherwise expand every folder.
function collapseToggleButton(
  context: FileTreeContext,
  data: FileTreeNode[],
): DomphyElement {
  return {
    button: [collapseGlyph()],
    type: "button",
    ariaLabel: "Toggle all folders",
    onClick: () => {
      context.expandedIds.set(
        context.expandedIds.get().length > 0 ? [] : collectExpandableIds(data),
      );
    },
    _key: "__collapse",
    style: {
      position: "absolute",
      insetInlineEnd: themeSpacing(2),
      bottom: themeSpacing(1),
      display: "inline-flex",
      alignItems: "center",
      justifyContent: "center",
      width: themeSpacing(8),
      height: themeSpacing(8),
      padding: themeSpacing(1),
      borderRadius: themeSpacing(1.5),
      border: "none",
      cursor: "pointer",
      color: (listener: Listener) =>
        themeColor(listener, "shift-9", context.color),
      backgroundColor: "transparent",
      transition: "background-color 150ms ease",
      "&:hover": {
        backgroundColor: (listener: Listener) =>
          themeColor(listener, "shift-2", context.color),
      },
    } as StyleObject,
  } as DomphyElement;
}

/**
 * A nested, expandable file/folder browser with accordion-style reveal, an
 * open/closed folder icon swap, and click-to-select rows (both files and
 * folders are selectable; a folder click also toggles its expansion). 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 ?? color;

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

  // Auto-expand the selected node's ancestor path so it is revealed on mount
  // (upstream's `expandSpecificTargetedElements` effect). Add-only merge — it
  // never collapses folders the caller pre-opened.
  const initialSelected = context.selectedId.get();
  if (initialSelected) {
    const found = findSelectionPath(data, initialSelected);
    if (found) {
      const toReveal = found.selectable ? found.path : found.path.slice(0, -1);
      const current = context.expandedIds.get();
      const merged = [...new Set([...current, ...toReveal])];
      if (merged.length !== current.length) context.expandedIds.set(merged);
    }
  }

  const sortedRoots = sortNodes(data, sort);
  const children = sortedRoots.map((node) => buildNode(node, context));
  if (props.collapseButton) children.push(collapseToggleButton(context, data));

  return {
    div: children,
    dir: direction,
    role: "tree",
    style: {
      position: "relative",
      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