[FilesRefresh] Add tree/tree-item widgets

This is the preparation to replace the directory tree component
in the Files app.

Supported features:
* basic interaction: select/expand/collapse
* keyboard navigation (a11y)

Upcoming support:
* Rename
* GM3 style

Demo: http://go/scrcast/NTA5MzEyOTc0NjEyMDcwNHw3Yjk4Y2Y4MC1mNg
(visit chrome://file-manager/labs/labs.html in DEBUG mode)

Bug: b:242938200
Test: browser_tests --gtest_filter=*XfTree
Test: browser_tests --gtest_filter=*XfTreeItem
Change-Id: I0827b662744ba7519a09bcdce2c300ea6e6cf8e6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/4018453
Reviewed-by: Luciano Pacheco <lucmult@chromium.org>
Commit-Queue: Wenbo Jie <wenbojie@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1077294}
diff --git a/chrome/browser/ash/file_manager/file_manager_jstest.cc b/chrome/browser/ash/file_manager/file_manager_jstest.cc
index 7070cdab..bd7864a 100644
--- a/chrome/browser/ash/file_manager/file_manager_jstest.cc
+++ b/chrome/browser/ash/file_manager/file_manager_jstest.cc
@@ -345,3 +345,11 @@
 IN_PROC_BROWSER_TEST_F(FileManagerJsTest, NudgeContainer) {
   RunTestURL("containers/nudge_container_unittest.js");
 }
+
+IN_PROC_BROWSER_TEST_F(FileManagerJsTest, XfTree) {
+  RunTestURL("widgets/xf_tree_unittest.js");
+}
+
+IN_PROC_BROWSER_TEST_F(FileManagerJsTest, XfTreeItem) {
+  RunTestURL("widgets/xf_tree_item_unittest.js");
+}
diff --git a/ui/file_manager/file_manager/widgets/xf_base.ts b/ui/file_manager/file_manager/widgets/xf_base.ts
index 37cf958..01949fd 100644
--- a/ui/file_manager/file_manager/widgets/xf_base.ts
+++ b/ui/file_manager/file_manager/widgets/xf_base.ts
@@ -11,6 +11,8 @@
 
 import {customElement, property, query, state} from 'chrome://resources/mwc/lit/decorators.js';
 import {classMap} from 'chrome://resources/mwc/lit/directives/class-map.js';
+import {ifDefined} from 'chrome://resources/mwc/lit/directives/if-defined.js';
+import {styleMap} from 'chrome://resources/mwc/lit/directives/style-map.js';
 import {css, html, LitElement, PropertyValues} from 'chrome://resources/mwc/lit/index.js';
 
 export {
@@ -18,10 +20,12 @@
   css,
   customElement,
   html,
+  ifDefined,
   property,
   PropertyValues,
   query,
   state,
+  styleMap,
 };
 
 /**
diff --git a/ui/file_manager/file_manager/widgets/xf_tree.ts b/ui/file_manager/file_manager/widgets/xf_tree.ts
new file mode 100644
index 0000000..0cec9e7
--- /dev/null
+++ b/ui/file_manager/file_manager/widgets/xf_tree.ts
@@ -0,0 +1,381 @@
+// Copyright 2022 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {isRTL} from 'chrome://resources/js/util.js';
+
+import {css, customElement, html, query, state, XfBase} from './xf_base.js';
+import {TreeItemCollapsedEvent, TreeItemExpandedEvent, XfTreeItem} from './xf_tree_item.js';
+import {isTreeItem} from './xf_tree_util.js';
+
+/**
+ * <xf-tree> is the container of the <xf-tree-item> elements. An example
+ * DOM structure is like this:
+ *
+ * <xf-tree>
+ *   <xf-tree-item>
+ *     <xf-tree-item></xf-tree-item>
+ *   </xf-tree-item>
+ *   <xf-tree-item></xf-tree-item>
+ * </xf-tree>
+ *
+ * The selection and focus of <xf-tree-item> is controlled in <xf-tree>,
+ * this is because we need to make sure only one item is being selected or
+ * focused.
+ */
+@customElement('xf-tree')
+export class XfTree extends XfBase {
+  static get events() {
+    return {
+      /** Triggers when a tree item has been selected. */
+      TREE_SELECTION_CHANGED: 'tree_selection_changed',
+    } as const;
+  }
+
+  /** Return the selected tree item, could be null. */
+  get selectedItem(): XfTreeItem|null {
+    return this.selectedItem_;
+  }
+  set selectedItem(item: XfTreeItem|null) {
+    this.selectItem_(item);
+  }
+
+  /** The child tree items. */
+  get items(): XfTreeItem[] {
+    return this.items_;
+  }
+
+  /** The child tree items which can be tabbed/focused into. */
+  get tabbableItems(): XfTreeItem[] {
+    return this.items_.filter(item => !item.disabled);
+  }
+
+  /** The default unnamed slot to let consumer pass children tree items. */
+  @query('slot') private $childrenSlot_!: HTMLSlotElement;
+
+  /** The child tree items. */
+  private items_: XfTreeItem[] = [];
+  /**
+   * Maintain these in the tree level so we can make sure at most one tree item
+   * can be selected/focused.
+   */
+  private selectedItem_: XfTreeItem|null = null;
+  private focusedItem_: XfTreeItem|null = null;
+
+  /**
+   * Value to set aria-setsize, which is the number of the top level child tree
+   * items.
+   */
+  @state() private ariaSetSize_ = 0;
+
+  static override get styles() {
+    return getCSS();
+  }
+
+  override render() {
+    return html`
+      <ul
+        class="tree"
+        role="tree"
+        aria-setsize=${this.ariaSetSize_}
+        @click=${this.onTreeClicked_}
+        @dblclick=${this.onTreeDblClicked_}
+        @keydown=${this.onTreeKeyDown_}
+        @tree_item_expanded=${this.onTreeItemExpanded_}
+        @tree_item_collapsed=${this.onTreeItemCollapsed_}
+      >
+        <slot @slotchange=${this.onSlotChanged_}></slot>
+      </ul>
+    `;
+  }
+
+  private onSlotChanged_() {
+    const oldItems = new Set(this.items_);
+    // Update `items_` every time when the children slot changes (e.g.
+    // add/remove).
+    this.items_ = this.$childrenSlot_.assignedElements().filter(isTreeItem);
+    this.ariaSetSize_ = this.tabbableItems.length;
+
+    if (this.selectedItem_) {
+      const newItems = new Set(this.items_);
+      if (oldItems.has(this.selectedItem_) &&
+          !newItems.has(this.selectedItem_)) {
+        // If the currently selected item exists in `oldItems` but not in
+        // `newItems`, it means it's being removed from the children slot,
+        // we need to select the first .
+        this.selectedItem = this.tabbableItems[0]!;
+      }
+    }
+  }
+
+  /**
+   * Handles the expanded event of the tree item.
+   */
+  private onTreeItemExpanded_(e: TreeItemExpandedEvent) {
+    const treeItem = e.detail.item;
+    (treeItem as any).scrollIntoViewIfNeeded(false);
+  }
+
+  /**
+   * Handles the collapse event of the tree item.
+   */
+  private onTreeItemCollapsed_(e: TreeItemCollapsedEvent) {
+    const treeItem = e.detail.item;
+    // If the currently focused tree item (`oldFocusedItem`) is a descent of
+    // another tree item (`treeItem`) which is going to be collapsed, we need to
+    // mark the ancestor tree item (`this`) as focused.
+    if (this.focusedItem_ !== treeItem) {
+      const oldFocusedItem = this.focusedItem_;
+      if (oldFocusedItem && treeItem.contains(oldFocusedItem)) {
+        this.focusItem_(treeItem);
+      }
+    }
+  }
+
+  /** Called when the user clicks on a tree item. */
+  private async onTreeClicked_(e: MouseEvent) {
+    // Stop if the the click target is not a tree item.
+    const treeItem = e.target as XfTreeItem;
+    if (treeItem && !isTreeItem(treeItem)) {
+      return;
+    }
+
+    if (treeItem.disabled) {
+      e.stopImmediatePropagation();
+      e.preventDefault();
+      return;
+    }
+
+    // Use composed path to know which element inside the shadow root
+    // has been clicked.
+    const innerClickTarget = e.composedPath()[0] as HTMLElement;
+    if (innerClickTarget.className === 'expand-icon') {
+      treeItem.expanded = !treeItem.expanded;
+    } else {
+      treeItem.selected = true;
+    }
+  }
+
+  /** Called when the user double clicks on a tree item. */
+  private async onTreeDblClicked_(e: MouseEvent) {
+    // Stop if the the click target is not a tree item.
+    const treeItem = e.target as XfTreeItem;
+    if (treeItem && !isTreeItem(treeItem)) {
+      return;
+    }
+
+    if (treeItem.disabled) {
+      e.stopImmediatePropagation();
+      e.preventDefault();
+      return;
+    }
+
+    // Use composed path to know which element inside the shadow root
+    // has been clicked.
+    const innerClickTarget = e.composedPath()[0] as HTMLElement;
+    if (innerClickTarget.className !== 'expand-icon' &&
+        treeItem.hasChildren()) {
+      treeItem.expanded = !treeItem.expanded;
+    }
+  }
+
+  /**
+   * Handle the keydown within the tree, this mainly handles the navigation
+   * and the selection with the keyboard.
+   */
+  private onTreeKeyDown_(e: KeyboardEvent) {
+    if (e.ctrlKey) {
+      return;
+    }
+
+    if (!this.focusedItem_) {
+      return;
+    }
+
+    if (this.tabbableItems.length === 0) {
+      return;
+    }
+
+    let itemToFocus: XfTreeItem|null|undefined = null;
+    switch (e.key) {
+      case 'Enter':
+      case 'Space':
+        this.selectItem_(this.focusedItem_);
+        break;
+      case 'ArrowUp':
+        itemToFocus = this.getPreviousItem_(this.focusedItem_);
+        break;
+      case 'ArrowDown':
+        itemToFocus = this.getNextItem_(this.focusedItem_);
+        break;
+      case 'ArrowLeft':
+      case 'ArrowRight':
+        // Don't let back/forward keyboard shortcuts be used.
+        if (e.altKey) {
+          break;
+        }
+
+        const expandKey = isRTL() ? 'ArrowLeft' : 'ArrowRight';
+        if (e.key === expandKey) {
+          if (this.focusedItem_.hasChildren() && !this.focusedItem_.expanded) {
+            this.focusedItem_.expanded = true;
+          } else {
+            itemToFocus = this.focusedItem_.tabbableItems[0];
+          }
+        } else {
+          if (this.focusedItem_.expanded) {
+            this.focusedItem_.expanded = false;
+          } else {
+            itemToFocus = this.focusedItem_.parentItem;
+          }
+        }
+        break;
+      case 'Home':
+        itemToFocus = this.tabbableItems[0];
+        break;
+      case 'End':
+        itemToFocus = this.tabbableItems[this.tabbableItems.length - 1];
+        break;
+    }
+
+    if (itemToFocus) {
+      this.focusItem_(itemToFocus);
+      e.preventDefault();
+    }
+  }
+
+  /**
+   * Helper function that returns the next tabbable tree item.
+   */
+  private getNextItem_(item: XfTreeItem): XfTreeItem|null {
+    if (item.expanded && item.tabbableItems.length > 0) {
+      return item.tabbableItems[0]!;
+    }
+
+    return this.getNextHelper_(item);
+  }
+
+  /**
+   * Another helper function that returns the next tabbable tree item.
+   */
+  private getNextHelper_(item: XfTreeItem|null): XfTreeItem|null {
+    if (!item) {
+      return null;
+    }
+
+    const nextSibling = item.nextElementSibling as XfTreeItem | null;
+    if (nextSibling) {
+      if (nextSibling.disabled) {
+        return this.getNextHelper_(nextSibling);
+      }
+      return nextSibling;
+    }
+    return this.getNextHelper_(item.parentItem);
+  }
+
+  /**
+   * Helper function that returns the previous tabbable tree item.
+   */
+  private getPreviousItem_(item: XfTreeItem): XfTreeItem|null {
+    let previousSibling = item.previousElementSibling as XfTreeItem | null;
+    while (previousSibling && previousSibling.disabled) {
+      previousSibling =
+          previousSibling.previousElementSibling as XfTreeItem | null;
+    }
+    if (previousSibling) {
+      return this.getLastHelper_(previousSibling);
+    }
+    return item.parentItem;
+  }
+
+  /**
+   * Helper function that returns the last tabbable tree item in the subtree.
+   */
+  private getLastHelper_(item: XfTreeItem|null): XfTreeItem|null {
+    if (!item) {
+      return null;
+    }
+    if (item.expanded && item.tabbableItems.length > 0) {
+      const lastChild = item.tabbableItems[item.tabbableItems.length - 1]!;
+      return this.getLastHelper_(lastChild);
+    }
+    return item;
+  }
+
+  /**
+   * Make `itemToSelect` become the selected item in the tree, this will
+   * also unselect the previously selected tree item to make sure at most
+   * one tree item is selected in the tree.
+   */
+  private selectItem_(itemToSelect: XfTreeItem|null) {
+    if (itemToSelect === this.selectedItem_) {
+      return;
+    }
+    const previousSelectedItem = this.selectedItem_;
+    if (previousSelectedItem) {
+      previousSelectedItem.selected = false;
+    }
+    this.selectedItem_ = itemToSelect;
+    if (this.selectedItem_) {
+      this.selectedItem_.selected = true;
+      this.focusItem_(this.selectedItem_);
+      (this.selectedItem_ as any).scrollIntoViewIfNeeded(false);
+    }
+    const selectionChangeEvent: TreeSelectedChangedEvent =
+        new CustomEvent(XfTree.events.TREE_SELECTION_CHANGED, {
+          bubbles: true,
+          composed: true,
+          detail: {
+            previousSelectedItem,
+            selectedItem: this.selectedItem,
+          },
+        });
+    this.dispatchEvent(selectionChangeEvent);
+  }
+
+  /**
+   * Make `itemToSelect` become the focused item in the tree, this will
+   * also unfocus the previously focused tree item to make sure at most
+   * one tree item is selected in the tree.
+   */
+  private focusItem_(itemToFocus: XfTreeItem) {
+    const previousFocusedItem = this.focusedItem_;
+    if (previousFocusedItem) {
+      previousFocusedItem.blur();
+    }
+    this.focusedItem_ = itemToFocus;
+    this.focusedItem_.focus();
+  }
+}
+
+function getCSS() {
+  return css`
+    :host {
+      display: block;
+    }
+
+    ul {
+      list-style: none;
+      margin: 0;
+      padding: 0;
+    }
+  `;
+}
+
+/** Type of the tree item selection custom event. */
+export type TreeSelectedChangedEvent = CustomEvent<{
+  /** The tree item which has been selected previously. */
+  previousSelectedItem: XfTreeItem | null,
+  /** The tree item which has been selected now. */
+  selectedItem: XfTreeItem | null,
+}>;
+
+declare global {
+  interface HTMLElementEventMap {
+    [XfTree.events.TREE_SELECTION_CHANGED]: TreeSelectedChangedEvent;
+  }
+
+  interface HTMLElementTagNameMap {
+    'xf-tree': XfTree;
+  }
+}
diff --git a/ui/file_manager/file_manager/widgets/xf_tree_item.ts b/ui/file_manager/file_manager/widgets/xf_tree_item.ts
new file mode 100644
index 0000000..c1395e1
--- /dev/null
+++ b/ui/file_manager/file_manager/widgets/xf_tree_item.ts
@@ -0,0 +1,467 @@
+// Copyright 2022 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {css, customElement, html, ifDefined, property, PropertyValues, query, state, styleMap, XfBase} from './xf_base.js';
+import type {XfTree} from './xf_tree.js';
+import {isTree, isTreeItem} from './xf_tree_util.js';
+
+/**
+ * The number of pixels to indent per level.
+ */
+const INDENT = 22;
+
+@customElement('xf-tree-item')
+export class XfTreeItem extends XfBase {
+  /**
+   * Override the tabIndex because we need to assign it to the <li> element
+   * instead of the host element.
+   */
+  @property({attribute: false}) override tabIndex: number = -1;
+
+  /**
+   * `separator` attribute will show a top border for the tree item. It's
+   * mainly used to identify this tree item is a start of the new section.
+   */
+  @property({type: Boolean, reflect: true}) separator = false;
+  /**
+   * Indicate if a tree item is disabled or not. Disabled tree item will have
+   * a grey out color, can't be selected, can't get focus. It can still have
+   * children, but it can't be expanded, and the expand icon will be hidden.
+   */
+  @property({type: Boolean, reflect: true}) disabled = false;
+  /** Indicate if a tree item has been selected or not. */
+  @property({type: Boolean, reflect: true}) selected = false;
+  /** Indicate if a tree item has been expanded or not. */
+  @property({type: Boolean, reflect: true}) expanded = false;
+
+  /**
+   * A tree item will have children if the child tree items have been inserted
+   * to its default slot. Only use `mayHaveChildren` if we want the tree item
+   * to appeared as having children even without the actual child tree items
+   * (e.g. no DOM children). This is mainly used when we asynchronously loads
+   * child tree items.
+   */
+  @property({type: Boolean, reflect: true, attribute: 'may-have-children'})
+  mayHaveChildren = false;
+
+  /** The icon of the tree item, will be displayed before the label text. */
+  @property({type: String, reflect: true, attribute: 'icon'}) icon = '';
+  /** The label text of the tree item. */
+  @property({type: String, reflect: true}) label = '';
+
+  static get events() {
+    return {
+      /** Triggers when a tree item has been expanded. */
+      TREE_ITEM_EXPANDED: 'tree_item_expanded',
+      /** Triggers when a tree item has been collapsed. */
+      TREE_ITEM_COLLAPSED: 'tree_item_collapsed',
+    } as const;
+  }
+
+  /**
+   * Override to focus the inner <li> instead of the host element.
+   * We use tabIndex to control if a tree item can be focused or not, need
+   * to set it to 0 before focusing the item.
+   */
+  override focus() {
+    console.assert(
+        !this.disabled,
+        'Called focus() on a disabled XfTreeItem() isn\'t allowed');
+
+    this.tabIndex = 0;
+    this.$treeItem_.focus();
+  }
+
+  /**
+   * Override to blur the inner <li> instead of the host element.
+   * We use tabIndex to control if a tree item can be focused or not, need
+   * to set it to -1 after blurring the item.
+   */
+  override blur() {
+    console.assert(
+        !this.disabled,
+        'Called blur() on a disabled XfTreeItem() isn\'t allowed');
+
+    this.tabIndex = -1;
+    this.$treeItem_.blur();
+  }
+
+  /** The level of the tree item, starting from 1. */
+  get level(): number {
+    return this.level_;
+  }
+
+  /** The child tree items. */
+  get items(): XfTreeItem[] {
+    return this.items_;
+  }
+
+  /** The child tree items which can be tabbed. */
+  get tabbableItems(): XfTreeItem[] {
+    return this.items_.filter(item => !item.disabled);
+  }
+
+  hasChildren(): boolean {
+    return this.mayHaveChildren || this.items_.length > 0;
+  }
+
+  /**
+   * Return the parent XfTreeItem if there is one, for top level XfTreeItem
+   * which doesn't have parent XfTreeItem, return null.
+   */
+  get parentItem(): XfTreeItem|null {
+    let p = this.parentElement;
+    while (p) {
+      if (isTreeItem(p)) {
+        return p;
+      }
+      if (isTree(p)) {
+        return null;
+      }
+      p = p.parentElement;
+    }
+    return p;
+  }
+
+  get tree(): XfTree|null {
+    let t = this.parentElement;
+    while (t && !isTree(t)) {
+      t = t.parentElement;
+    }
+    return t;
+  }
+
+  /**
+   * Expands all parent items.
+   */
+  reveal() {
+    let pi = this.parentItem;
+    while (pi) {
+      pi.expanded = true;
+      pi = pi.parentItem;
+    }
+  }
+
+  static override get styles() {
+    return getCSS();
+  }
+
+  /**
+   * Indicate the level of this tree item, we use it to calculate the padding
+   * indentation. Note: "aria-level" can be calculated by DOM structure so
+   * no need to provide it explicitly.
+   */
+  @state() private level_ = 1;
+
+  @query('li') private $treeItem_!: HTMLLIElement;
+  @query('slot:not([name])') private $childrenSlot_!: HTMLSlotElement;
+
+  /** The child tree items. */
+  private items_: XfTreeItem[] = [];
+
+  override render() {
+    const showExpandIcon = this.hasChildren() && !this.disabled;
+    const treeRowStyles = {
+      paddingInlineStart: `${Math.max(0, INDENT * (this.level_ - 1))}px`,
+    };
+
+    return html`
+      <li
+        class="tree-item"
+        role="treeitem"
+        tabindex=${this.tabIndex}
+        aria-labelledby="tree-label"
+        aria-selected=${this.selected}
+        aria-expanded=${ifDefined(showExpandIcon ? this.expanded : undefined)}
+        aria-disabled=${this.disabled}
+      >
+        <div
+          class="tree-row"
+          style=${styleMap(treeRowStyles)}
+        >
+          <span class="expand-icon"></span>
+          <span
+            class="tree-label-icon"
+            tree-icon-type=${this.icon}
+          ></span>
+          <span
+            class="tree-label"
+            id="tree-label"
+          >${this.label || ''}</span>
+          <slot name="trailingIcon"></slot>
+        </div>
+        <ul
+          class="tree-children"
+          role="group"
+        >
+          <slot @slotchange=${this.onSlotChanged_}></slot>
+        </ul>
+      </li>
+    `;
+  }
+
+  override connectedCallback() {
+    super.connectedCallback();
+    if (!this.tree) {
+      throw new Error(
+          '<xf-tree-item> can not be used without a parent <xf-tree>');
+    }
+  }
+
+  private onSlotChanged_() {
+    const oldItems = new Set(this.items_);
+    // Update `items_` every time when the children slot changes (e.g.
+    // add/remove).
+    this.items_ = this.$childrenSlot_.assignedElements().filter(isTreeItem);
+
+    let updateScheduled = false;
+
+    // If an expanded item's last children is deleted, update expanded property.
+    if (this.items_.length === 0 && this.expanded) {
+      this.expanded = false;
+      updateScheduled = true;
+    }
+
+    if (this.tree?.selectedItem) {
+      const newItems = new Set(this.items_);
+      if (oldItems.has(this.tree.selectedItem) &&
+          !newItems.has(this.tree.selectedItem)) {
+        // If the currently selected item exists in `oldItems` but not in
+        // `newItems`, it means it's being removed from the children slot,
+        // we need to select the parent node of the removed item (i.e. `this`).
+        this.selected = true;
+        updateScheduled = true;
+      }
+    }
+
+    if (!updateScheduled) {
+      // Explicitly trigger an update because render() relies on hasChildren().
+      this.requestUpdate();
+    }
+  }
+
+  override firstUpdated() {
+    this.updateLevel_();
+  }
+
+  override updated(changedProperties: PropertyValues<this>) {
+    super.updated(changedProperties);
+    if (changedProperties.has('expanded')) {
+      this.onExpandChanged_();
+    }
+    if (changedProperties.has('selected')) {
+      this.onSelectedChanged_();
+    }
+  }
+
+  private onExpandChanged_() {
+    if (this.expanded) {
+      const expandedEvent: TreeItemExpandedEvent =
+          new CustomEvent(XfTreeItem.events.TREE_ITEM_EXPANDED, {
+            bubbles: true,
+            composed: true,
+            detail: {item: this},
+          });
+      this.dispatchEvent(expandedEvent);
+    } else {
+      const collapseEvent: TreeItemCollapsedEvent =
+          new CustomEvent(XfTreeItem.events.TREE_ITEM_COLLAPSED, {
+            bubbles: true,
+            composed: true,
+            detail: {item: this},
+          });
+      this.dispatchEvent(collapseEvent);
+    }
+  }
+
+  private onSelectedChanged_() {
+    const tree = this.tree;
+    if (this.selected) {
+      this.reveal();
+      if (tree) {
+        tree.selectedItem = this;
+      }
+    } else {
+      if (tree && tree.selectedItem === this) {
+        tree.selectedItem = null;
+      }
+    }
+  }
+
+  /** Update the level of the tree item by traversing upwards. */
+  private updateLevel_() {
+    // Traverse upwards to determine the level.
+    let level = 0;
+    let current: XfTreeItem|null = this;
+    while (current) {
+      current = current.parentItem;
+      level++;
+    }
+    this.level_ = level;
+  }
+}
+
+function getCSS() {
+  return css`
+    ul {
+      list-style: none;
+      margin: 0;
+      outline: none;
+      padding: 0;
+    }
+
+    li {
+      display: block;
+    }
+
+    li:focus-visible {
+      outline: none;
+    }
+
+    :host([separator])::before {
+      border-bottom: 1px solid var(--cros-separator-color);
+      content: '';
+      display: block;
+      margin: 8px 0;
+      width: 100%;
+    }
+
+    .tree-row {
+      align-items: center;
+      border: 2px solid transparent;
+      border-inline-start-width: 0 !important;
+      border-radius: 0 20px 20px 0;
+      box-sizing: border-box;
+      color: var(--cros-text-color-primary);
+      cursor: default;
+      display: flex;
+      height: 32px;
+      margin-inline-end: 6px;
+      padding: 4px 0;
+      position: relative;
+      user-select: none;
+      white-space: nowrap;
+    }
+
+    :host-context(html[dir=rtl]) .tree-row {
+      border-radius: 20px 0 0 20px;
+    }
+
+    li:focus-visible .tree-row {
+      border: 2px solid var(--cros-focus-ring-color);
+      z-index: 2;
+    }
+
+    :host([selected]) .tree-row {
+      background-color: var(--cros-highlight-color);
+      color: var(--cros-text-color-selection);
+    }
+
+    :host([disabled]) .tree-row {
+      pointer-events: none;
+      opacity: var(--cros-disabled-opacity);
+    }
+
+    :host(:not([selected]):not([disabled])) .tree-row:hover {
+      background-color: var(--cros-ripple-color);
+    }
+
+    .expand-icon {
+      -webkit-mask-image: url(../foreground/images/files/ui/sort_desc.svg);
+      -webkit-mask-position: center;
+      -webkit-mask-repeat: no-repeat;
+      background-color: currentColor;
+      box-sizing: border-box;
+      flex: none;
+      height: 32px;
+      padding: 6px;
+      position: relative;
+      transform: rotate(-90deg);
+      transition: all 150ms;
+      visibility: hidden;
+      width: 32px;
+    }
+
+    li[aria-expanded] .expand-icon {
+      visibility: visible;
+    }
+
+    :host-context(html[dir=rtl]) .expand-icon {
+      transform: rotate(90deg);
+    }
+
+    :host([expanded]) .expand-icon {
+      transform: rotate(0);
+    }
+
+    .tree-label-icon {
+      -webkit-mask-position: center;
+      -webkit-mask-repeat: no-repeat;
+      background-color: var(--cros-icon-color-primary);
+      background-image: none;
+      flex: none;
+      height: 20px;
+      left: -4px;
+      position: relative;
+      right: -4px;
+      width: 20px;
+    }
+
+   :host[selected] .tree-label-icon {
+      background-color: var(--cros-icon-color-selection);
+    }
+
+    .tree-label {
+      display: block;
+      flex: auto;
+      font-weight: 500;
+      margin: 0 12px;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: pre;
+    }
+
+    /* We need to ensure that even empty labels take up space */
+    .tree-label:empty::after {
+      content: ' ';
+      white-space: pre;
+    }
+
+    .tree-children {
+      display: none;
+    }
+
+    :host([expanded]) .tree-children {
+      display: block;
+    }
+
+    slot[name="trailingIcon"]::slotted(*) {
+      height: 20px;
+      margin: 0;
+      width: 20px;
+    }
+  `;
+}
+
+/** Type of the tree item expanded custom event. */
+export type TreeItemExpandedEvent = CustomEvent<{
+  /** The tree item which has been expanded. */
+  item: XfTreeItem,
+}>;
+/** Type of the tree item collapsed custom event. */
+export type TreeItemCollapsedEvent = CustomEvent<{
+  /** The tree item which has been collapsed. */
+  item: XfTreeItem,
+}>;
+
+declare global {
+  interface HTMLElementEventMap {
+    [XfTreeItem.events.TREE_ITEM_EXPANDED]: TreeItemExpandedEvent;
+    [XfTreeItem.events.TREE_ITEM_COLLAPSED]: TreeItemCollapsedEvent;
+  }
+
+  interface HTMLElementTagNameMap {
+    'xf-tree-item': XfTreeItem;
+  }
+}
diff --git a/ui/file_manager/file_manager/widgets/xf_tree_item_unittest.ts b/ui/file_manager/file_manager/widgets/xf_tree_item_unittest.ts
new file mode 100644
index 0000000..baa94b0
--- /dev/null
+++ b/ui/file_manager/file_manager/widgets/xf_tree_item_unittest.ts
@@ -0,0 +1,397 @@
+// Copyright 2022 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assertEquals, assertFalse, assertNotEquals, assertTrue} from 'chrome://webui-test/chai_assert.js';
+import {eventToPromise} from 'chrome://webui-test/test_util.js';
+
+import {waitForElementUpdate} from '../common/js/unittest_util.js';
+
+import {XfTree} from './xf_tree.js';
+import {TreeItemCollapsedEvent, TreeItemExpandedEvent, XfTreeItem} from './xf_tree_item.js';
+
+/** Construct a single tree item. */
+async function setUpSingleTreeItem() {
+  document.body.innerHTML =
+      '<xf-tree><xf-tree-item id="item1" label="item1"></xf-tree-item></xf-tree>';
+  const element = document.querySelector('xf-tree-item');
+  assertNotEquals(null, element);
+  await waitForElementUpdate(element!);
+}
+
+/** Construct a tree with nested tree items. */
+async function setUpNestedTreeItems() {
+  // Tree structure:
+  // ── item1
+  //    ├── item1a
+  //    └── item1b
+  //        └── item1bi
+  // ── item2
+  document.body.innerHTML = '<xf-tree><xf-tree>';
+  const tree = document.querySelector('xf-tree')!;
+  assertNotEquals(null, tree);
+
+  const item1 = document.createElement('xf-tree-item');
+  item1.id = 'item1';
+  const item1a = document.createElement('xf-tree-item');
+  item1a.id = 'item1a';
+  const item1b = document.createElement('xf-tree-item');
+  item1b.id = 'item1b';
+  const item1bi = document.createElement('xf-tree-item');
+  item1bi.id = 'item1bi';
+  const item2 = document.createElement('xf-tree-item');
+  item2.id = 'item2';
+
+  item1b.appendChild(item1bi);
+  item1.appendChild(item1a);
+  item1.appendChild(item1b);
+  tree.appendChild(item1);
+  tree.appendChild(item2);
+
+  await waitForElementUpdate(tree);
+}
+
+/** Helper method to get tree item by id. */
+function getTree(): XfTree {
+  return document.querySelector('xf-tree')!;
+}
+
+/** Helper method to get tree item by id. */
+function getTreeItemById(id: string): XfTreeItem {
+  return document.querySelector(`xf-tree-item#${id}`)!;
+}
+
+/** Helper method to get inner elements from a tree item. */
+function getTreeItemInnerElements(treeItem: XfTreeItem): {
+  root: HTMLLIElement,
+  treeRow: HTMLDivElement,
+  expandIcon: HTMLSpanElement,
+  treeLabel: HTMLSpanElement,
+  treeLabelIcon: HTMLSpanElement,
+  trailingIcon: HTMLSlotElement,
+  treeChildren: HTMLUListElement,
+} {
+  return {
+    root: treeItem.shadowRoot!.querySelector('li')!,
+    treeRow: treeItem.shadowRoot!.querySelector('.tree-row')!,
+    expandIcon: treeItem.shadowRoot!.querySelector('.expand-icon')!,
+    treeLabel: treeItem.shadowRoot!.querySelector('.tree-label')!,
+    treeLabelIcon: treeItem.shadowRoot!.querySelector('.tree-label-icon')!,
+    trailingIcon:
+        treeItem.shadowRoot!.querySelector('slot[name="trailingIcon"]')!,
+    treeChildren: treeItem.shadowRoot!.querySelector('.tree-children')!,
+  };
+}
+
+/** Tests tree item can be rendered without tree or child tree items. */
+export async function testRenderWithSingleTreeItem(done: () => void) {
+  await setUpSingleTreeItem();
+  const item1 = getTreeItemById('item1');
+  const {root, treeRow, expandIcon, treeLabel, treeChildren} =
+      getTreeItemInnerElements(item1);
+
+  // Check item1's parent/children.
+  assertEquals(1, item1.level);
+  assertEquals(0, item1.items.length);
+  assertEquals(null, item1.parentItem);
+  assertEquals(getTree(), item1.tree);
+
+  // Test attributes on the root element.
+  assertEquals('treeitem', root.getAttribute('role'));
+  assertEquals('-1', root.getAttribute('tabindex'));
+  assertEquals('false', root.getAttribute('aria-selected'));
+  assertFalse(root.hasAttribute('aria-expanded'));
+  assertEquals('false', root.getAttribute('aria-disabled'));
+  assertEquals(treeLabel.id, root.getAttribute('aria-labelledby'));
+
+  // Test inner elements.
+  assertEquals('0px', treeRow.style.paddingInlineStart);
+  assertEquals('hidden', window.getComputedStyle(expandIcon).visibility);
+  assertEquals('item1', treeLabel.textContent);
+  assertEquals('group', treeChildren.getAttribute('role'));
+
+  done();
+}
+
+/** Tests tree item can be rendered with child tree items. */
+export async function testRenderWithTreeItems(done: () => void) {
+  await setUpNestedTreeItems();
+  const tree = getTree();
+
+  // Check item1's parent/children.
+  const item1 = getTreeItemById('item1');
+  const {root: root1, expandIcon: expandIcon1} =
+      getTreeItemInnerElements(item1);
+  assertEquals('false', root1.getAttribute('aria-expanded'));
+  assertEquals(2, item1.items.length);
+  assertEquals('item1a', item1.items[0]!.id);
+  assertEquals('item1b', item1.items[1]!.id);
+  assertEquals(null, item1.parentItem);
+  assertEquals(tree, item1.tree);
+  assertEquals('visible', window.getComputedStyle(expandIcon1).visibility);
+
+  // Check item1b's parent/children.
+  const item1b = getTreeItemById('item1b');
+  const {root: root1b, expandIcon: expandIcon1b} =
+      getTreeItemInnerElements(item1);
+  assertEquals('false', root1b.getAttribute('aria-expanded'));
+  assertEquals(1, item1b.items.length);
+  assertEquals('item1bi', item1b.items[0]!.id);
+  assertEquals(item1, item1b.parentItem);
+  assertEquals(tree, item1b.tree);
+  assertEquals('visible', window.getComputedStyle(expandIcon1b).visibility);
+
+  done();
+}
+
+/** Tests "may-have-children" attribute. */
+export async function testMayHaveChildrenAttribute(done: () => void) {
+  await setUpSingleTreeItem();
+
+  const item1 = getTreeItemById('item1');
+  const {root} = getTreeItemInnerElements(item1);
+  // Expand icon is hidden by default (no aria-expanded).
+  assertFalse(root.hasAttribute('aria-expanded'));
+  // Set may-have-children=true.
+  item1.mayHaveChildren = true;
+  await waitForElementUpdate(item1);
+  // Expand-icon should be visible now (has aria-expanded).
+  assertTrue(root.hasAttribute('aria-expanded'));
+  assertTrue(item1.hasChildren());
+
+  done();
+}
+
+/** Tests tree item level will be correctly updated. */
+export async function testTreeItemLevel(done: () => void) {
+  await setUpNestedTreeItems();
+
+  const item1 = getTreeItemById('item1');
+  const item1a = getTreeItemById('item1a');
+  const item1b = getTreeItemById('item1b');
+  const item1bi = getTreeItemById('item1bi');
+  const item2 = getTreeItemById('item2');
+
+  assertEquals(1, item1.level);
+  assertEquals(2, item1a.level);
+  assertEquals(2, item1b.level);
+  assertEquals(3, item1bi.level);
+  assertEquals(1, item2.level);
+
+  done();
+}
+
+/** Tests trialing icon can be rendered correctly. */
+export async function testTrailingIcon(done: () => void) {
+  await setUpSingleTreeItem();
+  const item1 = getTreeItemById('item1');
+
+  // Add a trailing icon for item1.
+  const icon = document.createElement('span');
+  icon.slot = 'trailingIcon';
+  item1.appendChild(icon);
+
+  const {trailingIcon} = getTreeItemInnerElements(item1);
+  const slotElements = trailingIcon.assignedElements();
+  assertEquals(1, slotElements.length);
+  assertEquals(icon, slotElements[0]);
+
+  done();
+}
+
+/**
+ * Tests disabled tree item won't be included in the tabbable items.
+ */
+export async function testDisabledTreeItem(done: () => void) {
+  await setUpNestedTreeItems();
+
+  // By default item1 has 2 tabbable items.
+  const item1 = getTreeItemById('item1');
+  assertEquals(2, item1.tabbableItems.length);
+
+  // Disable item1b.
+  const item1b = getTreeItemById('item1b');
+  item1b.disabled = true;
+  await waitForElementUpdate(item1b);
+
+  // aria-disabled should be true and expand icon should be hidden.
+  const {root, expandIcon} = getTreeItemInnerElements(item1b);
+  assertEquals('true', root.getAttribute('aria-disabled'));
+  assertEquals('hidden', window.getComputedStyle(expandIcon).visibility);
+
+  // item1b will be ignored in tabbable items.
+  assertEquals(2, item1.items.length);
+  assertEquals(1, item1.tabbableItems.length);
+  assertEquals('item1a', item1.tabbableItems[0]!.id);
+
+  done();
+}
+
+/** Tests tree item can be selected. */
+export async function testSelectTreeItem(done: () => void) {
+  await setUpNestedTreeItems();
+
+  // Select item1bi.
+  const item1bi = getTreeItemById('item1bi');
+  item1bi.selected = true;
+  await waitForElementUpdate(item1bi);
+
+  const {root} = getTreeItemInnerElements(item1bi);
+  assertEquals('true', root.getAttribute('aria-selected'));
+  const tree = getTree();
+  assertEquals('item1bi', tree.selectedItem!.id);
+
+  // All its parent chain will be expanded.
+  const item1b = getTreeItemById('item1b');
+  const item1 = getTreeItemById('item1');
+  assertTrue(item1b.expanded);
+  assertTrue(item1.expanded);
+
+  // Unselect item1bi.
+  item1bi.selected = false;
+  await waitForElementUpdate(item1bi);
+
+  assertEquals('false', root.getAttribute('aria-selected'));
+  assertEquals(null, tree.selectedItem);
+
+  done();
+}
+
+
+/** Tests tree item can be expanded. */
+export async function testExpandTreeItem(done: () => void) {
+  await setUpNestedTreeItems();
+
+  // By default children items are not displayed.
+  const item1 = getTreeItemById('item1');
+  const {treeChildren} = getTreeItemInnerElements(item1);
+  assertEquals('none', window.getComputedStyle(treeChildren).display);
+
+  // Expand item1.
+  const itemExpandedEventPromise: Promise<TreeItemExpandedEvent> =
+      eventToPromise(XfTreeItem.events.TREE_ITEM_EXPANDED, item1);
+  item1.expanded = true;
+  await waitForElementUpdate(item1);
+  const {root} = getTreeItemInnerElements(item1);
+  assertEquals('true', root.getAttribute('aria-expanded'));
+
+  // Assert the event is triggered.
+  const itemExpandedEvent = await itemExpandedEventPromise;
+  assertEquals(item1, itemExpandedEvent.detail.item);
+
+  // Assert the children items are shown.
+  assertEquals('block', window.getComputedStyle(treeChildren).display);
+
+  done();
+}
+
+/**
+ * Tests tree item can be collapsed.
+ */
+export async function testCollapseTreeItem(done: () => void) {
+  await setUpNestedTreeItems();
+
+  // Select item1b.
+  const item1b = getTreeItemById('item1b');
+  item1b.selected = true;
+  await waitForElementUpdate(item1b);
+
+  const item1 = getTreeItemById('item1');
+  const {treeChildren} = getTreeItemInnerElements(item1);
+  await waitForElementUpdate(item1);
+  assertTrue(item1.expanded);
+
+  // Collapse item1.
+  const itemCollapsedEventPromise: Promise<TreeItemCollapsedEvent> =
+      eventToPromise(XfTreeItem.events.TREE_ITEM_COLLAPSED, item1);
+  item1.expanded = false;
+  await waitForElementUpdate(item1);
+  const {root} = getTreeItemInnerElements(item1);
+  assertEquals('false', root.getAttribute('aria-expanded'));
+
+  // Assert the event is triggered.
+  const itemCollapsedEvent = await itemCollapsedEventPromise;
+  assertEquals(item1, itemCollapsedEvent.detail.item);
+
+  // Assert the children items are hidden.
+  assertEquals('none', window.getComputedStyle(treeChildren).display);
+
+  done();
+}
+
+/** Tests adding/removing tree items. */
+export async function testAddRemoveTreeItems(done: () => void) {
+  await setUpSingleTreeItem();
+  const item1 = getTreeItemById('item1');
+
+  // Add item1a as a child to item1.
+  const item1a = document.createElement('xf-tree-item');
+  item1a.id = 'item1a';
+  item1.appendChild(item1a);
+  await waitForElementUpdate(item1);
+  assertEquals(1, item1.items.length);
+  assertEquals('item1a', item1.items[0]!.id);
+
+  // Add item1b as a child to item1.
+  const item1b = document.createElement('xf-tree-item');
+  item1b.id = 'item1b';
+  item1.appendChild(item1b);
+  await waitForElementUpdate(item1);
+  assertEquals(2, item1.items.length);
+  assertEquals('item1b', item1.items[1]!.id);
+
+  // Remove item1a.
+  item1.removeChild(item1a);
+  await waitForElementUpdate(item1);
+  assertEquals(1, item1.items.length);
+  assertEquals('item1b', item1.items[0]!.id);
+
+  done();
+}
+
+/** Tests expanded item will become collapsed when last child is removed. */
+export async function testRemoveChildForExpandedItem(done: () => void) {
+  await setUpNestedTreeItems();
+
+  // Expand item1.
+  const item1 = getTreeItemById('item1');
+  item1.expanded = true;
+  await waitForElementUpdate(item1);
+
+  // Remove item1a.
+  const item1a = getTreeItemById('item1a');
+  item1.removeChild(item1a);
+  await waitForElementUpdate(item1);
+  assertTrue(item1.expanded);
+
+  // Remove item1b.
+  const item1b = getTreeItemById('item1b');
+  item1.removeChild(item1b);
+  await waitForElementUpdate(item1);
+
+  // item1 will be collapsed because all its children are removed.
+  assertFalse(item1.expanded);
+
+  done();
+}
+
+/** Tests removal of the selected item. */
+export async function testRemoveSelectedItem(done: () => void) {
+  await setUpNestedTreeItems();
+
+  // Select item1a.
+  const item1a = getTreeItemById('item1a');
+  item1a.selected = true;
+  await waitForElementUpdate(item1a);
+
+  // Remove item1a.
+  const item1 = getTreeItemById('item1');
+  assertFalse(item1.selected);
+  item1.removeChild(item1a);
+  await waitForElementUpdate(item1);
+
+  // item1 will be selected instead.
+  assertTrue(item1.selected);
+
+  done();
+}
diff --git a/ui/file_manager/file_manager/widgets/xf_tree_unittest.ts b/ui/file_manager/file_manager/widgets/xf_tree_unittest.ts
new file mode 100644
index 0000000..be6e908b
--- /dev/null
+++ b/ui/file_manager/file_manager/widgets/xf_tree_unittest.ts
@@ -0,0 +1,737 @@
+// Copyright 2022 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {assertArrayEquals, assertEquals, assertFalse, assertNotEquals, assertTrue} from 'chrome://webui-test/chai_assert.js';
+import {eventToPromise} from 'chrome://webui-test/test_util.js';
+
+import {waitForElementUpdate} from '../common/js/unittest_util.js';
+
+import {TreeSelectedChangedEvent, XfTree} from './xf_tree.js';
+import {TreeItemCollapsedEvent, TreeItemExpandedEvent, XfTreeItem} from './xf_tree_item.js';
+
+export function setUp() {
+  document.body.innerHTML = '<xf-tree></xf-tree>';
+}
+
+async function getTree(): Promise<XfTree> {
+  const element = document.querySelector('xf-tree');
+  assertNotEquals(null, element);
+  await waitForElementUpdate(element!);
+  return element!;
+}
+
+/** Helper method to get tree root <ul>. */
+function getTreeRoot(tree: XfTree): HTMLUListElement {
+  return tree.shadowRoot!.querySelector('ul')!;
+}
+
+/** Helper method to get tree item by id. */
+function getTreeItemById(id: string): XfTreeItem {
+  return document.querySelector(`xf-tree-item#${id}`)!;
+}
+
+/** Helper method to get the ids of elements in the tab order. */
+function getTabbableTreeIds(tree: XfTree): string[] {
+  const allItems: XfTreeItem[] =
+      Array.from(tree.querySelectorAll('xf-tree-item'));
+  return allItems.filter(el => el.tabIndex !== -1).map(el => el.id);
+}
+
+function sendKeyDownEvent(tree: XfTree, key: string) {
+  const keyDownEvent = new KeyboardEvent('keydown', {key});
+  getTreeRoot(tree).dispatchEvent(keyDownEvent);
+}
+
+function simulateDoubleClick(element: HTMLElement) {
+  element.dispatchEvent(new MouseEvent('dblclick', {
+    bubbles: true,
+    composed: true,
+  }));
+}
+
+/**
+ * Helper method that checks that focused item is correct,
+ * and tab orders updated so only the focused item is tabbable.
+ */
+function checkFocusedItemToBe(tree: XfTree, id: string): boolean {
+  const item = getTreeItemById(id);
+  const tabbableIds = getTabbableTreeIds(tree);
+  return document.activeElement === item && tabbableIds.length === 1 &&
+      tabbableIds[0] === id;
+}
+
+/** Construct a tree with only direct children. */
+async function appendDirectTreeItems(tree: XfTree) {
+  // Tree structure:
+  // ── item1
+  // ── item2
+  const item1 = document.createElement('xf-tree-item');
+  item1.id = 'item1';
+  const item2 = document.createElement('xf-tree-item');
+  item2.id = 'item2';
+  tree.appendChild(item1);
+  tree.appendChild(item2);
+  await waitForElementUpdate(tree);
+}
+
+/** Construct a tree with nested children. */
+async function appendNestedTreeItems(tree: XfTree) {
+  // Tree structure:
+  // ── item1
+  //    ├── item1a
+  //    └── item1b
+  //        └── item1bi
+  // ── item2
+  const item1 = document.createElement('xf-tree-item');
+  item1.id = 'item1';
+  const item1a = document.createElement('xf-tree-item');
+  item1a.id = 'item1a';
+  const item1b = document.createElement('xf-tree-item');
+  item1b.id = 'item1b';
+  const item1bi = document.createElement('xf-tree-item');
+  item1bi.id = 'item1bi';
+  const item2 = document.createElement('xf-tree-item');
+  item2.id = 'item2';
+
+  item1b.appendChild(item1bi);
+  item1.appendChild(item1a);
+  item1.appendChild(item1b);
+  tree.appendChild(item1);
+  tree.appendChild(item2);
+
+  await waitForElementUpdate(tree);
+}
+
+/** Tests tree element can render without child tree items. */
+export async function testRenderWithoutTreeItems(done: () => void) {
+  const tree = await getTree();
+  const treeRoot = getTreeRoot(tree);
+  assertEquals('tree', treeRoot.getAttribute('role'));
+  assertEquals('0', treeRoot.getAttribute('aria-setsize'));
+  assertEquals(0, tree.items.length);
+
+  done();
+}
+
+/** Tests tree element can render with child tree items. */
+export async function testRenderWithTreeItems(done: () => void) {
+  const tree = await getTree();
+  await appendDirectTreeItems(tree);
+
+  const treeRoot = getTreeRoot(tree);
+  assertEquals('2', treeRoot.getAttribute('aria-setsize'));
+  assertEquals(2, tree.items.length);
+  assertEquals('item1', tree.items[0]!.id);
+  assertEquals('item2', tree.items[1]!.id);
+
+  done();
+}
+
+/** Tests tree selection change. */
+export async function testTreeSelectionChange(done: () => void) {
+  const tree = await getTree();
+  await appendDirectTreeItems(tree);
+
+  // Change at the tree item level.
+  const selectionChangeEventPromise1: Promise<TreeSelectedChangedEvent> =
+      eventToPromise(XfTree.events.TREE_SELECTION_CHANGED, tree);
+  const item1 = getTreeItemById('item1');
+  const item2 = getTreeItemById('item2');
+  item1.selected = true;
+  await waitForElementUpdate(tree);
+  const selectionChangeEvent1 = await selectionChangeEventPromise1;
+  assertEquals(item1, tree.selectedItem);
+  assertEquals(null, selectionChangeEvent1.detail.previousSelectedItem);
+  assertEquals(item1, selectionChangeEvent1.detail.selectedItem);
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+
+  // Change at the tree level.
+  const selectionChangeEventPromise2: Promise<TreeSelectedChangedEvent> =
+      eventToPromise(XfTree.events.TREE_SELECTION_CHANGED, tree);
+  tree.selectedItem = item2;
+  const selectionChangeEvent2 = await selectionChangeEventPromise2;
+  assertFalse(item1.selected);
+  assertTrue(item2.selected);
+  assertEquals(item1, selectionChangeEvent2.detail.previousSelectedItem);
+  assertEquals(item2, selectionChangeEvent2.detail.selectedItem);
+  assertTrue(checkFocusedItemToBe(tree, 'item2'));
+
+  done();
+}
+
+/** Tests tree item navigation by pressing home and end key. */
+export async function testHomeAndEndNavigation(done: () => void) {
+  const tree = await getTree();
+  await appendNestedTreeItems(tree);
+
+  const item1bi = getTreeItemById('item1bi');
+  // Expand item1 and item1b, then focus item1bi.
+  item1bi.selected = true;
+  await waitForElementUpdate(tree);
+  assertArrayEquals(['item1bi'], getTabbableTreeIds(tree));
+  // Home -> item1.
+  sendKeyDownEvent(tree, 'Home');
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+  // End -> item2.
+  sendKeyDownEvent(tree, 'End');
+  assertTrue(checkFocusedItemToBe(tree, 'item2'));
+
+  done();
+}
+
+/** Tests tree item navigation by pressing arrow up and down key. */
+export async function testArrowUpAndDownNavigation(done: () => void) {
+  const tree = await getTree();
+  await appendNestedTreeItems(tree);
+
+  // Select and focus item1.
+  const item1 = getTreeItemById('item1');
+  item1.selected = true;
+  await waitForElementUpdate(tree);
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+
+  // By default all items are collapsed.
+  // ArrowDown -> item2.
+  sendKeyDownEvent(tree, 'ArrowDown');
+  assertTrue(checkFocusedItemToBe(tree, 'item2'));
+  // ArrowUp -> item1.
+  sendKeyDownEvent(tree, 'ArrowUp');
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+
+  // Expand item1.
+  item1.expanded = true;
+  await waitForElementUpdate(tree);
+  // ArrowDown -> item1a.
+  sendKeyDownEvent(tree, 'ArrowDown');
+  assertTrue(checkFocusedItemToBe(tree, 'item1a'));
+  // ArrowDown -> item1b.
+  sendKeyDownEvent(tree, 'ArrowDown');
+  assertTrue(checkFocusedItemToBe(tree, 'item1b'));
+  // ArrowDown -> item2.
+  sendKeyDownEvent(tree, 'ArrowDown');
+  assertTrue(checkFocusedItemToBe(tree, 'item2'));
+  // ArrowUp -> item1b.
+  sendKeyDownEvent(tree, 'ArrowUp');
+  assertTrue(checkFocusedItemToBe(tree, 'item1b'));
+
+  // Expand item1b.
+  const item1b = getTreeItemById('item1b');
+  item1b.expanded = true;
+  await waitForElementUpdate(tree);
+  // ArrowDown -> item1bi.
+  sendKeyDownEvent(tree, 'ArrowDown');
+  assertTrue(checkFocusedItemToBe(tree, 'item1bi'));
+  // ArrowDown -> item2.
+  sendKeyDownEvent(tree, 'ArrowDown');
+  assertTrue(checkFocusedItemToBe(tree, 'item2'));
+  // ArrowUp -> item1bi.
+  sendKeyDownEvent(tree, 'ArrowUp');
+  assertTrue(checkFocusedItemToBe(tree, 'item1bi'));
+  // ArrowUp -> item1b.
+  sendKeyDownEvent(tree, 'ArrowUp');
+  assertTrue(checkFocusedItemToBe(tree, 'item1b'));
+  // ArrowUp -> item1a.
+  sendKeyDownEvent(tree, 'ArrowUp');
+  assertTrue(checkFocusedItemToBe(tree, 'item1a'));
+  // ArrowUp -> item1.
+  sendKeyDownEvent(tree, 'ArrowUp');
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+
+  done();
+}
+
+/** Tests no affect for arrow up key if tree item has no previous sibling. */
+export async function testArrowUpForItemWithoutPreviousSibling(
+    done: () => void) {
+  const tree = await getTree();
+  await appendNestedTreeItems(tree);
+
+  // Select item1 (no previous sibling).
+  const item1 = getTreeItemById('item1');
+  item1.selected = true;
+  await waitForElementUpdate(tree);
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+
+  // ArrowUp -> item1 is still focused.
+  sendKeyDownEvent(tree, 'ArrowUp');
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+
+  // ArrowUp again.
+  sendKeyDownEvent(tree, 'ArrowUp');
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+
+  done();
+}
+
+/** Tests no affect for arrow down key if tree item has no next sibling. */
+export async function testArrowDownForItemWithoutNextSibling(done: () => void) {
+  const tree = await getTree();
+  await appendNestedTreeItems(tree);
+
+  // Select item2 (no next sibling).
+  const item2 = getTreeItemById('item2');
+  item2.selected = true;
+  await waitForElementUpdate(tree);
+  assertTrue(checkFocusedItemToBe(tree, 'item2'));
+
+  // ArrowDown -> item2 is still focused.
+  sendKeyDownEvent(tree, 'ArrowDown');
+  assertTrue(checkFocusedItemToBe(tree, 'item2'));
+
+  // ArrowDown again.
+  sendKeyDownEvent(tree, 'ArrowDown');
+  assertTrue(checkFocusedItemToBe(tree, 'item2'));
+
+  done();
+}
+
+/** Tests tree item expand/collapse by pressing arrow left and right key. */
+export async function testArrowLeftAndRightNavigation(done: () => void) {
+  const tree = await getTree();
+  await appendNestedTreeItems(tree);
+
+  // Select item1.
+  const item1 = getTreeItemById('item1');
+  item1.selected = true;
+  await waitForElementUpdate(tree);
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+
+  // ArrowRight -> expand item1.
+  sendKeyDownEvent(tree, 'ArrowRight');
+  await waitForElementUpdate(tree);
+  assertTrue(item1.expanded);
+  // Selected/focus item should not be changed.
+  assertEquals(item1, tree.selectedItem);
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+  // ArrowRight -> focus first child item1a.
+  sendKeyDownEvent(tree, 'ArrowRight');
+  assertTrue(checkFocusedItemToBe(tree, 'item1a'));
+  // ArrowLeft -> item1.
+  sendKeyDownEvent(tree, 'ArrowLeft');
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+  // Selected item and expand status should not be changed.
+  assertEquals(item1, tree.selectedItem);
+  assertTrue(item1.expanded);
+  // ArrowRight -> item1a.
+  sendKeyDownEvent(tree, 'ArrowRight');
+  assertTrue(checkFocusedItemToBe(tree, 'item1a'));
+  // ArrowDown -> item1b.
+  sendKeyDownEvent(tree, 'ArrowDown');
+  assertTrue(checkFocusedItemToBe(tree, 'item1b'));
+  // ArrowRight -> expand item1b.
+  sendKeyDownEvent(tree, 'ArrowRight');
+  const item1b = getTreeItemById('item1b');
+  await waitForElementUpdate(tree);
+  assertTrue(item1b.expanded);
+  // ArrowRight -> item1bi.
+  sendKeyDownEvent(tree, 'ArrowRight');
+  assertTrue(checkFocusedItemToBe(tree, 'item1bi'));
+  // Select item1bi.
+  const item1bi = getTreeItemById('item1bi');
+  item1bi.selected = true;
+  await waitForElementUpdate(tree);
+  // ArrowLeft -> item1b.
+  sendKeyDownEvent(tree, 'ArrowLeft');
+  assertTrue(checkFocusedItemToBe(tree, 'item1b'));
+  assertTrue(item1b.expanded);
+  assertFalse(item1b.selected);
+  // ArrowLeft -> collapse item1b.
+  sendKeyDownEvent(tree, 'ArrowLeft');
+  await waitForElementUpdate(tree);
+  assertFalse(item1b.expanded);
+  // ArrowLeft -> item1.
+  sendKeyDownEvent(tree, 'ArrowLeft');
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+  assertTrue(item1.expanded);
+  // ArrowLeft -> collapse item1.
+  sendKeyDownEvent(tree, 'ArrowLeft');
+  await waitForElementUpdate(tree);
+  assertFalse(item1.expanded);
+
+  done();
+}
+
+/** Tests no affect for arrow left key if tree item has no parent. */
+export async function testArrowLeftForItemWithoutParent(done: () => void) {
+  const tree = await getTree();
+  await appendNestedTreeItems(tree);
+
+  // Select item1 (no parent).
+  const item1 = getTreeItemById('item1');
+  item1.selected = true;
+  await waitForElementUpdate(tree);
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+
+  // ArrowLeft -> item1 is still focused.
+  sendKeyDownEvent(tree, 'ArrowLeft');
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+
+  // ArrowLeft again.
+  sendKeyDownEvent(tree, 'ArrowLeft');
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+
+  done();
+}
+
+/** Tests no affect for arrow right key if tree item has no children. */
+export async function testArrowRightForItemWithoutChildren(done: () => void) {
+  const tree = await getTree();
+  await appendNestedTreeItems(tree);
+
+  // Select item2 (no children).
+  const item2 = getTreeItemById('item2');
+  item2.selected = true;
+  await waitForElementUpdate(tree);
+  assertTrue(checkFocusedItemToBe(tree, 'item2'));
+
+  // ArrowRight -> item2 is still focused.
+  sendKeyDownEvent(tree, 'ArrowRight');
+  assertTrue(checkFocusedItemToBe(tree, 'item2'));
+  assertFalse(item2.expanded);
+
+  // ArrowRight again.
+  sendKeyDownEvent(tree, 'ArrowRight');
+  assertTrue(checkFocusedItemToBe(tree, 'item2'));
+  assertFalse(item2.expanded);
+
+  done();
+}
+
+/**
+ * Tests tree item expand/collapse by pressing arrow left and right key in
+ * RTL mode.
+ */
+export async function testArrowLeftAndRightNavigationInRTL(done: () => void) {
+  document.documentElement.setAttribute('dir', 'rtl');
+  const tree = await getTree();
+  await appendNestedTreeItems(tree);
+
+  // Select item1.
+  const item1 = getTreeItemById('item1');
+  item1.selected = true;
+  await waitForElementUpdate(tree);
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+
+  // ArrowLeft -> expand item1.
+  sendKeyDownEvent(tree, 'ArrowLeft');
+  await waitForElementUpdate(tree);
+  assertTrue(item1.expanded);
+  // Selected/focus item should not be changed.
+  assertEquals(item1, tree.selectedItem);
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+  // ArrowLeft -> focus first child item1a.
+  sendKeyDownEvent(tree, 'ArrowLeft');
+  assertTrue(checkFocusedItemToBe(tree, 'item1a'));
+  // ArrowRight -> item1.
+  sendKeyDownEvent(tree, 'ArrowRight');
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+  // Selected item and expand status should not be changed.
+  assertEquals(item1, tree.selectedItem);
+  assertTrue(item1.expanded);
+  // ArrowLeft -> item1a.
+  sendKeyDownEvent(tree, 'ArrowLeft');
+  assertTrue(checkFocusedItemToBe(tree, 'item1a'));
+  // ArrowDown -> item1b.
+  sendKeyDownEvent(tree, 'ArrowDown');
+  assertTrue(checkFocusedItemToBe(tree, 'item1b'));
+  // ArrowLeft -> expand item1b.
+  sendKeyDownEvent(tree, 'ArrowLeft');
+  await waitForElementUpdate(tree);
+  const item1b = getTreeItemById('item1b');
+  assertTrue(item1b.expanded);
+  // ArrowLeft -> item1bi.
+  sendKeyDownEvent(tree, 'ArrowLeft');
+  assertTrue(checkFocusedItemToBe(tree, 'item1bi'));
+  // Select item1bi.
+  const item1bi = getTreeItemById('item1bi');
+  item1bi.selected = true;
+  await waitForElementUpdate(tree);
+  // ArrowRight -> item1b.
+  sendKeyDownEvent(tree, 'ArrowRight');
+  assertTrue(checkFocusedItemToBe(tree, 'item1b'));
+  assertTrue(item1b.expanded);
+  assertFalse(item1b.selected);
+  // ArrowRight -> collapse item1b.
+  sendKeyDownEvent(tree, 'ArrowRight');
+  await waitForElementUpdate(tree);
+  assertFalse(item1b.expanded);
+  // ArrowRight -> item1.
+  sendKeyDownEvent(tree, 'ArrowRight');
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+  assertTrue(item1.expanded);
+  // ArrowRight -> collapse item1.
+  sendKeyDownEvent(tree, 'ArrowRight');
+  await waitForElementUpdate(tree);
+  assertFalse(item1.expanded);
+
+  document.documentElement.removeAttribute('dir');
+  done();
+}
+
+/** Tests tree item selection by pressing Enter/Space key. */
+export async function testEnterToSelectItem(done: () => void) {
+  const tree = await getTree();
+  await appendDirectTreeItems(tree);
+
+  const item1 = getTreeItemById('item1');
+  const item2 = getTreeItemById('item2');
+  item1.selected = true;
+  await waitForElementUpdate(tree);
+
+  // Use Enter to select item2.
+  const selectionChangeEventPromise1: Promise<TreeSelectedChangedEvent> =
+      eventToPromise(XfTree.events.TREE_SELECTION_CHANGED, tree);
+  sendKeyDownEvent(tree, 'ArrowDown');
+  assertTrue(checkFocusedItemToBe(tree, 'item2'));
+  sendKeyDownEvent(tree, 'Enter');
+  await waitForElementUpdate(tree);
+  const selectionChangeEvent1 = await selectionChangeEventPromise1;
+  assertTrue(item2.selected);
+  assertEquals(item2, tree.selectedItem);
+  assertEquals(item1, selectionChangeEvent1.detail.previousSelectedItem);
+  assertEquals(item2, selectionChangeEvent1.detail.selectedItem);
+
+  // Use Space to select item1.
+  const selectionChangeEventPromise2: Promise<TreeSelectedChangedEvent> =
+      eventToPromise(XfTree.events.TREE_SELECTION_CHANGED, tree);
+  sendKeyDownEvent(tree, 'ArrowUp');
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+  sendKeyDownEvent(tree, 'Space');
+  await waitForElementUpdate(tree);
+  const selectionChangeEvent2 = await selectionChangeEventPromise2;
+  assertTrue(item1.selected);
+  assertEquals(item1, tree.selectedItem);
+  assertEquals(item2, selectionChangeEvent2.detail.previousSelectedItem);
+  assertEquals(item1, selectionChangeEvent2.detail.selectedItem);
+
+  done();
+}
+
+/** Tests tree item can be expanded by single click. */
+export async function testExpandTreeItemByClick(done: () => void) {
+  const tree = await getTree();
+  await appendDirectTreeItems(tree);
+
+  // Single click on the expand-icon.
+  const item1 = getTreeItemById('item1');
+  const expandIcon =
+      item1.shadowRoot!.querySelector<HTMLSpanElement>('.expand-icon')!;
+  expandIcon.click();
+  await waitForElementUpdate(item1);
+
+  // item1 should be expanded, not selected.
+  assertTrue(item1.expanded);
+  assertFalse(item1.selected);
+
+  // Single click again on the expand-icon.
+  expandIcon.click();
+  await waitForElementUpdate(item1);
+
+  // item1 should be collapsed, not selected.
+  assertFalse(item1.expanded);
+  assertFalse(item1.selected);
+
+  done();
+}
+
+/** Tests tree item can be selected by single click. */
+export async function testSelectTreeItemByClick(done: () => void) {
+  const tree = await getTree();
+  await appendDirectTreeItems(tree);
+
+  // Single click on item1.
+  const item1 = getTreeItemById('item1');
+  item1.click();
+  await waitForElementUpdate(item1);
+
+  // item1 should be selected, not expanded.
+  assertFalse(item1.expanded);
+  assertTrue(item1.selected);
+
+  done();
+}
+
+/** Tests tree item can be expanded by double click. */
+export async function testExpandTreeItemByDoubleClick(done: () => void) {
+  const tree = await getTree();
+  await appendNestedTreeItems(tree);
+
+  // Double click on item1.
+  const item1 = getTreeItemById('item1');
+  simulateDoubleClick(item1);
+  await waitForElementUpdate(item1);
+
+  // item1 should be expanded.
+  assertTrue(item1.expanded);
+
+  // Double click again on item1.
+  simulateDoubleClick(item1);
+  await waitForElementUpdate(item1);
+
+  // item1 should be collapsed.
+  assertFalse(item1.expanded);
+
+  done();
+}
+
+/** Tests disabled tree item should be skipped for navigation. */
+export async function testSkipDisabledItem(done: () => void) {
+  const tree = await getTree();
+  await appendNestedTreeItems(tree);
+
+  // Select and focus item1, then expand it.
+  const item1 = getTreeItemById('item1');
+  item1.selected = true;
+  item1.expanded = true;
+  // Disable item1a.
+  const item1a = getTreeItemById('item1a');
+  item1a.disabled = true;
+  await waitForElementUpdate(tree);
+
+  // ArrowDown -> item1b, skip item1a.
+  sendKeyDownEvent(tree, 'ArrowDown');
+  assertTrue(checkFocusedItemToBe(tree, 'item1b'));
+  // ArrowUp -> item1, skip item1a.
+  sendKeyDownEvent(tree, 'ArrowUp');
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+
+  done();
+}
+
+/** Tests click/double click on the disabled item has no effects. */
+export async function testNoActionOnDisabledItem(done: () => void) {
+  const tree = await getTree();
+  await appendNestedTreeItems(tree);
+
+  // Select item2.
+  const item2 = getTreeItemById('item2');
+  item2.selected = true;
+  await waitForElementUpdate(item2);
+
+  // Disable item1.
+  const item1 = getTreeItemById('item1');
+  item1.disabled = true;
+  await waitForElementUpdate(item1);
+
+
+  // No response for single click.
+  assertFalse(item1.selected);
+  item1.click();
+  await waitForElementUpdate(item1);
+  assertFalse(item1.selected);
+  assertTrue(item2.selected);
+
+  // No response for single click the hidden expand icon.
+  assertFalse(item1.expanded);
+  const expandIcon =
+      item1.shadowRoot!.querySelector<HTMLSpanElement>('.expand-icon')!;
+  expandIcon.click();
+  await waitForElementUpdate(item1);
+  assertFalse(item1.expanded);
+
+  // No response for double click.
+  assertFalse(item1.expanded);
+  simulateDoubleClick(item1);
+  await waitForElementUpdate(item1);
+  assertFalse(item1.expanded);
+
+  done();
+}
+
+/** Tests adding/removing tree items. */
+export async function testAddRemoveTreeItems(done: () => void) {
+  const tree = await getTree();
+  await appendDirectTreeItems(tree);
+
+  // Add a new tree item: item3.
+  const item3 = document.createElement('xf-tree-item');
+  item3.id = 'item3';
+  tree.appendChild(item3);
+  await waitForElementUpdate(tree);
+  assertEquals('3', getTreeRoot(tree).getAttribute('aria-setsize'));
+  assertEquals(3, tree.items.length);
+  assertEquals('item3', tree.items[2]!.id);
+
+  // Remove tree item: item2.
+  const item2 = getTreeItemById('item2');
+  tree.removeChild(item2);
+  await waitForElementUpdate(tree);
+  assertEquals('2', getTreeRoot(tree).getAttribute('aria-setsize'));
+  assertEquals(2, tree.items.length);
+  assertEquals('item1', tree.items[0]!.id);
+  assertEquals('item3', tree.items[1]!.id);
+
+  done();
+}
+
+/** Tests removing a selected tree item should update selected properly. */
+export async function testSelectionUpdateAfterRemoving(done: () => void) {
+  const tree = await getTree();
+  await appendDirectTreeItems(tree);
+
+  // Select item2.
+  const item2 = getTreeItemById('item2');
+  item2.selected = true;
+  await waitForElementUpdate(tree);
+
+  // Remove item2.
+  tree.removeChild(item2);
+  await waitForElementUpdate(tree);
+
+  // The selected item should be item1 now.
+  const item1 = getTreeItemById('item1');
+  assertTrue(item1.selected);
+  assertEquals(item1, tree.selectedItem);
+
+  done();
+}
+
+/** Tests tree should be able to observe tree item event. */
+export async function testObserveTreeItemEvent(done: () => void) {
+  const tree = await getTree();
+  await appendDirectTreeItems(tree);
+
+  // Expand item1.
+  const itemExpandedEventPromise: Promise<TreeItemExpandedEvent> =
+      eventToPromise(XfTreeItem.events.TREE_ITEM_EXPANDED, tree);
+  const item1 = getTreeItemById('item1');
+  item1.expanded = true;
+  await waitForElementUpdate(tree);
+  const itemExpandedEvent = await itemExpandedEventPromise;
+  assertEquals(item1, itemExpandedEvent.detail.item);
+
+  // Collapse item1.
+  const itemCollapsedEventPromise: Promise<TreeItemCollapsedEvent> =
+      eventToPromise(XfTreeItem.events.TREE_ITEM_COLLAPSED, tree);
+  item1.expanded = false;
+  await waitForElementUpdate(tree);
+  const itemCollapsedEvent = await itemCollapsedEventPromise;
+  assertEquals(item1, itemCollapsedEvent.detail.item);
+
+  done();
+}
+
+/**
+ * Tests focus will move to its parent if the focused tree item is collapsed.
+ */
+export async function testFocusMoveToParentIfCollapsed(done: () => void) {
+  const tree = await getTree();
+  await appendNestedTreeItems(tree);
+
+  // Select item1b.
+  const item1b = getTreeItemById('item1b');
+  item1b.selected = true;
+  await waitForElementUpdate(tree);
+
+  // Collapse item1b's parent item1.
+  const item1 = getTreeItemById('item1');
+  assertTrue(item1.expanded);
+  item1.expanded = false;
+  await waitForElementUpdate(tree);
+
+  // Focus should move to item1.
+  assertTrue(checkFocusedItemToBe(tree, 'item1'));
+
+  done();
+}
diff --git a/ui/file_manager/file_manager/widgets/xf_tree_util.ts b/ui/file_manager/file_manager/widgets/xf_tree_util.ts
new file mode 100644
index 0000000..fd8c574
--- /dev/null
+++ b/ui/file_manager/file_manager/widgets/xf_tree_util.ts
@@ -0,0 +1,16 @@
+// Copyright 2022 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import type {XfTree} from './xf_tree.js';
+import type {XfTreeItem} from './xf_tree_item.js';
+
+/** Check if an `Element` is a tree or not. */
+export function isTree(element: Element): element is XfTree {
+  return element.tagName === 'XF-TREE';
+}
+
+/** Check if an `Element` is a tree item or not. */
+export function isTreeItem(element: Element): element is XfTreeItem {
+  return element.tagName === 'XF-TREE-ITEM';
+}
diff --git a/ui/file_manager/file_names.gni b/ui/file_manager/file_names.gni
index 6469f1fd..e55c42e 100644
--- a/ui/file_manager/file_names.gni
+++ b/ui/file_manager/file_names.gni
@@ -278,10 +278,15 @@
   "file_manager/widgets/xf_search_options.ts",
   "file_manager/widgets/xf_select.ts",
   "file_manager/foreground/js/ui/search_autocomplete_list.ts",
+  "file_manager/widgets/xf_tree.ts",
+  "file_manager/widgets/xf_tree_item.ts",
 
   # Foreground.
   "file_manager/foreground/js/file_tasks.ts",
   "file_manager/foreground/js/task_controller.ts",
+
+  # Util
+  "file_manager/widgets/xf_tree_util.ts",
 ]
 
 # Isolate Polymer TS to avoid sending them to Closure via the rule `js_from_ts`.
@@ -320,6 +325,8 @@
   "file_manager/widgets/xf_nudge_unittest.ts",
   "file_manager/widgets/xf_search_options_unittest.ts",
   "file_manager/widgets/xf_select_unittest.ts",
+  "file_manager/widgets/xf_tree_unittest.ts",
+  "file_manager/widgets/xf_tree_item_unittest.ts",
 
   # Foreground:
   "file_manager/foreground/js/file_tasks_unittest.ts",