[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",