UNPKG

chrome-devtools-frontend

Version:
1,343 lines (1,244 loc) • 55 kB
// Copyright 2020 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import { dispatchClickEvent, dispatchKeyDownEvent, dispatchMouseOutEvent, dispatchMouseOverEvent, getEventPromise, renderElementIntoDOM, stripLitHtmlCommentNodes, } from '../../../testing/DOMHelpers.js'; import {html} from '../../lit/lit.js'; import * as RenderCoordinator from '../render_coordinator/render_coordinator.js'; import * as TreeOutline from './tree_outline.js'; async function renderTreeOutline<TreeNodeDataType>({ tree, defaultRenderer, filter, }: { tree: TreeOutline.TreeOutline.TreeOutlineData<TreeNodeDataType>['tree'], // defaultRenderer is required usually but here we make it optinal and provide a default one as part of renderTreeOutline, to save duplication in every single test where we want to use a simple string renderer. defaultRenderer?: TreeOutline.TreeOutline.TreeOutlineData<TreeNodeDataType>['defaultRenderer'], filter?: TreeOutline.TreeOutline.TreeOutlineData<TreeNodeDataType>['filter'], }): Promise<{ component: TreeOutline.TreeOutline.TreeOutline<TreeNodeDataType>, shadowRoot: ShadowRoot, }> { const component = new TreeOutline.TreeOutline.TreeOutline<TreeNodeDataType>(); const data: TreeOutline.TreeOutline.TreeOutlineData<TreeNodeDataType> = { tree, defaultRenderer: defaultRenderer || ((node: TreeOutline.TreeOutlineUtils.TreeNode<TreeNodeDataType>) => html`${node.treeNodeData}`), filter, }; component.data = data; renderElementIntoDOM(component); assert.isNotNull(component.shadowRoot); await RenderCoordinator.done(); return { component, shadowRoot: component.shadowRoot, }; } /** * Wait for a certain number of children are rendered. We need this as the * component uses Lit's until directive, which is async and not within the * render coordinator's control. */ async function waitForRenderedTreeNodeCount(shadowRoot: ShadowRoot, expectedNodeCount: number): Promise<void> { const actualNodeCount = shadowRoot.querySelectorAll('li[role="treeitem"]').length; if (actualNodeCount === expectedNodeCount) { return; } await new Promise<void>(resolve => { requestAnimationFrame(async () => { await waitForRenderedTreeNodeCount(shadowRoot, expectedNodeCount); resolve(); }); }); } function getFocusableTreeNode(shadowRoot: ShadowRoot): HTMLLIElement { const focusableNode = shadowRoot.querySelector<HTMLLIElement>('li[role="treeitem"][tabindex="0"]'); if (!focusableNode) { throw new Error('Could not find focused node in Tree shadow root'); } return focusableNode; } /* The structure represented by basicTreeData is: - Offices - Europe - UK - LON - 6PS - CSG - BEL - Germany - MUC - BER - Products - Chrome - YouTube - Drive - Calendar */ // These node is pulled out as we test expandAndSelectTreeNode and getPathToTreeNode with them. const nodeBelgraveHouse = { treeNodeData: 'BEL', id: 'BEL', }; const nodeLondon = { treeNodeData: 'LON', id: 'LON', children: async () => [{treeNodeData: '6PS', id: '6PS'}, {treeNodeData: 'CSG', id: 'CSG'}, nodeBelgraveHouse], }; const nodeUK = { treeNodeData: 'UK', id: 'UK', children: async () => [nodeLondon], }; const nodeEurope = { treeNodeData: 'Europe', id: 'Europe', children: async () => [nodeUK, { treeNodeData: 'Germany', id: 'Germany', children: async () => [{treeNodeData: 'MUC', id: 'MUC'}, {treeNodeData: 'BER', id: 'BER'}], }], }; const nodeOffices = { treeNodeData: 'Offices', id: 'Offices', children: async () => [nodeEurope], }; const basicTreeData: Array<TreeOutline.TreeOutlineUtils.TreeNode<string>> = [ nodeOffices, { treeNodeData: 'Products', id: '1', children: async () => [{ treeNodeData: 'Chrome', id: '2', }, { treeNodeData: 'YouTube', id: '3', }, { treeNodeData: 'Drive', id: '4', }, { treeNodeData: 'Calendar', id: '5', }], }, ]; /* The structure represented by nodeAustralia is: - Australia - SA - Adelaide - Toorak Gardens - Woodville South - Gawler - NSW - Glebe - Newtown - Camperdown */ const nodeAustralia = { treeNodeData: 'Australia', id: 'australia', children: async () => [{ treeNodeData: 'SA', id: 'sa', children: async () => [{ treeNodeData: 'Adelaide', id: 'adelaide', children: async () => [{treeNodeData: 'Toorak Gardens', id: 'toorak'}, {treeNodeData: 'Woodville South', id: 'woodville'}, {treeNodeData: 'Gawler', id: 'gawler'}, ], }, ], }, { treeNodeData: 'NSW', id: 'nsw', children: async () => [{treeNodeData: 'Glebe', id: 'glebe'}, {treeNodeData: 'Newtown', id: 'newtown'}, {treeNodeData: 'Camperdown', id: 'camperdown'}, ], }, ], }; const NODE_COUNT_BASIC_DATA_FULLY_EXPANDED = 15; const NODE_COUNT_BASIC_DATA_DEFAULT_EXPANDED = 12; interface VisibleTreeNodeFromDOM { renderedKey: string; children?: VisibleTreeNodeFromDOM[]; } /** * Converts the nodes into a tree structure that we can assert against. */ function visibleNodesToTree(shadowRoot: ShadowRoot): VisibleTreeNodeFromDOM[] { const tree: VisibleTreeNodeFromDOM[] = []; function buildTreeNode(node: HTMLLIElement): VisibleTreeNodeFromDOM { const item: VisibleTreeNodeFromDOM = { renderedKey: nodeKeyInnerHTML(node), }; if (node.getAttribute('aria-expanded') && node.getAttribute('aria-expanded') === 'true') { item.children = []; const childNodes = node.querySelectorAll<HTMLLIElement>(':scope > ul[role="group"]>li'); for (const child of childNodes) { item.children.push(buildTreeNode(child)); } } return item; } const rootNodes = shadowRoot.querySelectorAll<HTMLLIElement>('ul[role="tree"]>li'); for (const root of rootNodes) { tree.push(buildTreeNode(root)); } return tree; } function treeNodeKeyText(node: HTMLLIElement) { const keyNode = node.querySelector('[data-node-key]'); if (!keyNode) { throw new Error('Found tree node without a key within it.'); } return keyNode.getAttribute('data-node-key') || ''; } function nodeKeyInnerHTML(node: HTMLLIElement) { const keyNode = node.querySelector('[data-node-key]'); if (!keyNode) { throw new Error('Found tree node without a key within it.'); } return stripLitHtmlCommentNodes(keyNode.innerHTML); } function getVisibleTreeNodeByText(shadowRoot: ShadowRoot, text: string): HTMLLIElement { const nodes = shadowRoot.querySelectorAll<HTMLLIElement>('li[role="treeitem"]'); const matchingNode = Array.from(nodes).find(node => { return treeNodeKeyText(node) === text; }); if (!matchingNode) { throw new Error(`Could not find tree item with text ${text}.`); } return matchingNode; } describe('TreeOutline', () => { it('renders with all non-root nodes hidden by default', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const visibleItems = shadowRoot.querySelectorAll<HTMLLIElement>('li[role="treeitem"]'); assert.lengthOf(visibleItems, 2); const itemText1 = treeNodeKeyText(visibleItems[0]); const itemText2 = treeNodeKeyText(visibleItems[1]); assert.strictEqual(itemText1, 'Offices'); assert.strictEqual(itemText2, 'Products'); }); it('expands a node when the arrow icon is clicked', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const rootNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); const arrowIcon = rootNode.querySelector<HTMLSpanElement>('.arrow-icon'); assert.instanceOf(arrowIcon, HTMLSpanElement); dispatchClickEvent(arrowIcon); await waitForRenderedTreeNodeCount(shadowRoot, 3); const visibleTree = visibleNodesToTree(shadowRoot); assert.deepEqual(visibleTree, [ {renderedKey: 'Offices', children: [{renderedKey: 'Europe'}]}, {renderedKey: 'Products'}, ]); }); it('does not expand nodes when clicking outside of the arrow by default', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const rootNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); dispatchClickEvent(rootNode); await waitForRenderedTreeNodeCount(shadowRoot, 2); const visibleTree = visibleNodesToTree(shadowRoot); assert.deepEqual(visibleTree, [ {renderedKey: 'Offices'}, {renderedKey: 'Products'}, ]); }); it('can be configured to expand nodes when any part of the node is clicked', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); component.setAttribute('clickabletitle', ''); const rootNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); dispatchClickEvent(rootNode); await waitForRenderedTreeNodeCount(shadowRoot, 3); const visibleTree = visibleNodesToTree(shadowRoot); assert.deepEqual(visibleTree, [ {renderedKey: 'Offices', children: [{renderedKey: 'Europe'}]}, {renderedKey: 'Products'}, ]); }); describe('nowrap attribute', () => { it('sets the white-space to initial by default', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const rootNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); const key = rootNode.querySelector('[data-node-key]'); assert.instanceOf(key, HTMLElement); const whiteSpaceValue = window.getComputedStyle(key).getPropertyValue('white-space'); assert.strictEqual(whiteSpaceValue, 'normal'); }); it('will set white-space: nowrap if the attribute is set', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); component.setAttribute('nowrap', ''); const rootNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); const key = rootNode.querySelector('[data-node-key]'); assert.instanceOf(key, HTMLElement); const whiteSpaceValue = window.getComputedStyle(key).getPropertyValue('white-space'); assert.strictEqual(whiteSpaceValue, 'nowrap'); }); }); describe('toplevelbordercolor attribute', () => { it('by default the nodes are not given a border', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const rootNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); const borderTopValue = window.getComputedStyle(rootNode).getPropertyValue('border-top'); // Odd assertion: this is the default borderTop the browser "applies" if none is set. assert.strictEqual(borderTopValue, '0px none rgb(0, 0, 0)'); }); it('gives the nodes a border if the attribute is set', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); component.setAttribute('toplevelbordercolor', 'rgb(255, 0, 0)'); const rootNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); const borderTopValue = window.getComputedStyle(rootNode).getPropertyValue('border-top'); assert.strictEqual(borderTopValue, '1px solid rgb(255, 0, 0)'); }); }); it('can take nodes with a custom key type', async () => { interface CustomTreeKeyType { property: string; value: string; } const customRenderer = (node: TreeOutline.TreeOutlineUtils.TreeNode<CustomTreeKeyType>) => { return html`<h2 class="item">${node.treeNodeData.property.toUpperCase()}:</h2>${node.treeNodeData.value}`; }; const tinyTree: Array<TreeOutline.TreeOutlineUtils.TreeNode<CustomTreeKeyType>> = [{ treeNodeData: {property: 'name', value: 'jack'}, id: '0', renderer: customRenderer, children: async () => [{ renderer: customRenderer, id: '1', treeNodeData: {property: 'locationGroupName', value: 'EMEA'}, }, { renderer: customRenderer, id: '2', treeNodeData: {property: 'locationGroupName', value: 'USA'}, }, { renderer: customRenderer, id: '3', treeNodeData: {property: 'locationGroupName', value: 'APAC'}, }], }]; const {component, shadowRoot} = await renderTreeOutline({ tree: tinyTree, }); await component.expandRecursively(); await waitForRenderedTreeNodeCount(shadowRoot, 4); const visibleTree = visibleNodesToTree(shadowRoot); assert.deepEqual(visibleTree, [ { renderedKey: '<h2 class="item">NAME:</h2>jack', children: [ { renderedKey: '<h2 class="item">LOCATIONGROUPNAME:</h2>EMEA', }, { renderedKey: '<h2 class="item">LOCATIONGROUPNAME:</h2>USA', }, { renderedKey: '<h2 class="item">LOCATIONGROUPNAME:</h2>APAC', }, ], }, ]); }); it('can recursively expand the tree, going 3 levels deep by default', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(); await waitForRenderedTreeNodeCount(shadowRoot, 12); const visibleTree = visibleNodesToTree(shadowRoot); assert.deepEqual(visibleTree, [ { renderedKey: 'Offices', children: [{ renderedKey: 'Europe', children: [ {renderedKey: 'UK', children: [{renderedKey: 'LON'}]}, {renderedKey: 'Germany', children: [{renderedKey: 'MUC'}, {renderedKey: 'BER'}]}, ], }], }, { renderedKey: 'Products', children: [ { renderedKey: 'Chrome', }, { renderedKey: 'YouTube', }, { renderedKey: 'Drive', }, { renderedKey: 'Calendar', }, ], }, ]); }); describe('expandToAndSelectTreeNode', () => { it('expands the relevant part of the tree to reveal the given node', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandToAndSelectTreeNode(nodeBelgraveHouse); await waitForRenderedTreeNodeCount(shadowRoot, 9); const visibleTree = visibleNodesToTree(shadowRoot); // The tree is expanded down to include "BEL" but the rest of the tree is still collapsed. assert.deepEqual(visibleTree, [ { renderedKey: 'Offices', children: [{ renderedKey: 'Europe', children: [ { renderedKey: 'UK', children: [ {renderedKey: 'LON', children: [{renderedKey: '6PS'}, {renderedKey: 'CSG'}, {renderedKey: 'BEL'}]}, ], }, {renderedKey: 'Germany'}, ], }], }, { renderedKey: 'Products', }, ]); }); it('selects the given node once the tree has been expanded', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandToAndSelectTreeNode(nodeBelgraveHouse); // Wait for the tree to be expanded await waitForRenderedTreeNodeCount(shadowRoot, 9); // Wait for the coordinator to have called focus() on the right node await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'BEL'), ); }); }); it('can recursively collapse all children of a node', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(Number.POSITIVE_INFINITY); await waitForRenderedTreeNodeCount(shadowRoot, NODE_COUNT_BASIC_DATA_FULLY_EXPANDED); const europeNode = getVisibleTreeNodeByText(shadowRoot, 'Europe'); await component.collapseChildrenOfNode(europeNode); await waitForRenderedTreeNodeCount(shadowRoot, 7); const visibleTree = visibleNodesToTree(shadowRoot); assert.deepEqual(visibleTree, [ { renderedKey: 'Offices', children: [{ renderedKey: 'Europe', }], }, { renderedKey: 'Products', children: [ { renderedKey: 'Chrome', }, { renderedKey: 'YouTube', }, { renderedKey: 'Drive', }, { renderedKey: 'Calendar', }, ], }, ]); }); it('can collapse all nodes', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(Number.POSITIVE_INFINITY); await waitForRenderedTreeNodeCount(shadowRoot, NODE_COUNT_BASIC_DATA_FULLY_EXPANDED); await component.collapseAllNodes(); await waitForRenderedTreeNodeCount(shadowRoot, 2); const visibleTree = visibleNodesToTree(shadowRoot); assert.deepEqual(visibleTree, [ { renderedKey: 'Offices', }, { renderedKey: 'Products', }, ]); }); it('caches async child nodes and only fetches them once', async () => { const fetchChildrenSpy = sinon.spy< () => Promise<Array<TreeOutline.TreeOutlineUtils.TreeNode<string>>>>(async () => { return [ { treeNodeData: 'EMEA', id: '1', }, { treeNodeData: 'USA', id: '2', }, { treeNodeData: 'APAC', id: '3', }, ]; }); const tinyTree: Array<TreeOutline.TreeOutlineUtils.TreeNode<string>> = [ { treeNodeData: 'Offices', id: '0', children: fetchChildrenSpy, }, ]; const {component, shadowRoot} = await renderTreeOutline({ tree: tinyTree, }); // Expand it, then collapse it, then expand it again await component.expandRecursively(Number.POSITIVE_INFINITY); await waitForRenderedTreeNodeCount(shadowRoot, 4); sinon.assert.callCount(fetchChildrenSpy, 1); const officesNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); await component.collapseChildrenOfNode(officesNode); await waitForRenderedTreeNodeCount(shadowRoot, 1); await component.expandRecursively(Number.POSITIVE_INFINITY); await waitForRenderedTreeNodeCount(shadowRoot, 4); // Make sure that we only fetched the children once despite expanding the // Tree twice. sinon.assert.callCount(fetchChildrenSpy, 1); const visibleTree = visibleNodesToTree(shadowRoot); assert.deepEqual(visibleTree, [ { renderedKey: 'Offices', children: [ { renderedKey: 'EMEA', }, {renderedKey: 'USA'}, {renderedKey: 'APAC'}, ], }, ]); }); it('allows a node to have a custom renderer', async () => { const tinyTree: Array<TreeOutline.TreeOutlineUtils.TreeNode<string>> = [{ treeNodeData: 'Offices', id: 'Offices', renderer: node => html`<h2 class="top-node">${node.treeNodeData.toUpperCase()}</h2>`, children: async () => [{ treeNodeData: 'EMEA', id: 'EMEA', }, { treeNodeData: 'USA', id: 'USA', }, { treeNodeData: 'APAC', id: 'APAC', }], }]; const {component, shadowRoot} = await renderTreeOutline({ tree: tinyTree, }); await component.expandRecursively(Number.POSITIVE_INFINITY); await waitForRenderedTreeNodeCount(shadowRoot, 4); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); const key = officeNode.querySelector('[data-node-key]'); assert.instanceOf(key, HTMLElement); const renderedKey = stripLitHtmlCommentNodes(key.innerHTML); assert.strictEqual(renderedKey, '<h2 class="top-node">OFFICES</h2>'); }); it('passes the custom renderer the expanded state', async () => { const tinyTree: Array<TreeOutline.TreeOutlineUtils.TreeNode<string>> = [{ treeNodeData: 'Offices', id: 'Offices', renderer: (node, {isExpanded}) => { return html`<h2 class="top-node">${node.treeNodeData.toUpperCase()}. Expanded: ${isExpanded}</h2>`; }, children: async () => [{ treeNodeData: 'EMEA', id: 'EMEA', }, { treeNodeData: 'USA', id: 'USA', }, { treeNodeData: 'APAC', id: 'APAC', }], }]; const {component, shadowRoot} = await renderTreeOutline({ tree: tinyTree, }); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); const key = officeNode.querySelector('[data-node-key]'); assert.instanceOf(key, HTMLElement); let renderedKey = stripLitHtmlCommentNodes(key.innerHTML); assert.strictEqual(renderedKey, '<h2 class="top-node">OFFICES. Expanded: false</h2>'); await component.expandRecursively(Number.POSITIVE_INFINITY); await waitForRenderedTreeNodeCount(shadowRoot, 4); renderedKey = stripLitHtmlCommentNodes(key.innerHTML); assert.strictEqual(renderedKey, '<h2 class="top-node">OFFICES. Expanded: true</h2>'); }); describe('navigating with keyboard', () => { it('defaults to the first root node as active', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'Offices'), ); }); describe('pressing the ENTER key', () => { it('expands the node if it is closed', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); dispatchClickEvent(officeNode); await RenderCoordinator.done(); dispatchKeyDownEvent(officeNode, {key: 'Enter', bubbles: true}); await waitForRenderedTreeNodeCount(shadowRoot, 3); assert.strictEqual(officeNode.getAttribute('aria-expanded'), 'true'); }); it('closes the node if it is open', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); void component.expandRecursively(); await waitForRenderedTreeNodeCount(shadowRoot, NODE_COUNT_BASIC_DATA_DEFAULT_EXPANDED); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); dispatchClickEvent(officeNode); await RenderCoordinator.done(); dispatchKeyDownEvent(officeNode, {key: 'Enter', bubbles: true}); await waitForRenderedTreeNodeCount(shadowRoot, 6); assert.strictEqual(officeNode.getAttribute('aria-expanded'), 'false'); }); }); describe('pressing the SPACE key', () => { it('expands the node if it is closed', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); dispatchClickEvent(officeNode); await RenderCoordinator.done(); dispatchKeyDownEvent(officeNode, {key: ' ', bubbles: true}); await waitForRenderedTreeNodeCount(shadowRoot, 3); assert.strictEqual(officeNode.getAttribute('aria-expanded'), 'true'); }); it('closes the node if it is open', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(); await waitForRenderedTreeNodeCount(shadowRoot, 12); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); dispatchClickEvent(officeNode); await RenderCoordinator.done(); dispatchKeyDownEvent(officeNode, {key: ' ', bubbles: true}); await waitForRenderedTreeNodeCount(shadowRoot, 6); assert.strictEqual(officeNode.getAttribute('aria-expanded'), 'false'); }); }); describe('pressing the HOME key', () => { it('takes the user to the top most root node', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(Number.POSITIVE_INFINITY); await waitForRenderedTreeNodeCount(shadowRoot, NODE_COUNT_BASIC_DATA_FULLY_EXPANDED); const berlinNode = getVisibleTreeNodeByText(shadowRoot, 'BER'); dispatchClickEvent(berlinNode); await RenderCoordinator.done(); dispatchKeyDownEvent(berlinNode, {key: 'Home', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'Offices'), ); }); }); describe('pressing the END key', () => { it('takes the user to the last visible node if they are all expanded', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(Number.POSITIVE_INFINITY); await RenderCoordinator.done(); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); dispatchClickEvent(officeNode); dispatchKeyDownEvent(officeNode, {key: 'End', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), // Calendar is the very last node in the tree getVisibleTreeNodeByText(shadowRoot, 'Calendar'), ); }); it('does not expand any closed nodes and focuses the last visible node', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); // Expand the Offices part of the tree const arrowIcon = officeNode.querySelector<HTMLSpanElement>('.arrow-icon'); assert.instanceOf(arrowIcon, HTMLSpanElement); dispatchClickEvent(arrowIcon); await waitForRenderedTreeNodeCount(shadowRoot, 3); // Focus the "Europe" node. const europeNode = getVisibleTreeNodeByText(shadowRoot, 'Europe'); dispatchClickEvent(europeNode); dispatchKeyDownEvent(officeNode, {key: 'End', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), // Products is the last node in the tree, as its children are not expanded getVisibleTreeNodeByText(shadowRoot, 'Products'), ); }); }); describe('pressing the UP arrow', () => { it('does nothing if on the root node', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); dispatchClickEvent(officeNode); dispatchKeyDownEvent(officeNode, {key: 'ArrowUp', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), officeNode, ); }); it('moves focus to the previous sibling', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(); await waitForRenderedTreeNodeCount(shadowRoot, 12); const berlinNode = getVisibleTreeNodeByText(shadowRoot, 'BER'); dispatchClickEvent(berlinNode); dispatchKeyDownEvent(berlinNode, {key: 'ArrowUp', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'MUC'), ); }); it('moves focus to the parent if there are no previous siblings', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(); await waitForRenderedTreeNodeCount(shadowRoot, NODE_COUNT_BASIC_DATA_DEFAULT_EXPANDED); const ukNode = getVisibleTreeNodeByText(shadowRoot, 'UK'); dispatchClickEvent(ukNode); dispatchKeyDownEvent(ukNode, {key: 'ArrowUp', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'Europe'), ); }); it('moves focus to the parent\'s last child if there are no previous siblings and the parent is expanded', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(); await waitForRenderedTreeNodeCount(shadowRoot, NODE_COUNT_BASIC_DATA_DEFAULT_EXPANDED); const germanyNode = getVisibleTreeNodeByText(shadowRoot, 'Germany'); dispatchClickEvent(germanyNode); dispatchKeyDownEvent(germanyNode, {key: 'ArrowUp', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'LON'), ); }); it('moves focus to the parent\'s deeply nested last child if there are no previous siblings and the parent has children that are expanded', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(Number.POSITIVE_INFINITY); await waitForRenderedTreeNodeCount(shadowRoot, NODE_COUNT_BASIC_DATA_FULLY_EXPANDED); const germanyNode = getVisibleTreeNodeByText(shadowRoot, 'Germany'); dispatchClickEvent(germanyNode); dispatchKeyDownEvent(germanyNode, {key: 'ArrowUp', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'BEL'), ); }); }); describe('pressing the RIGHT arrow', () => { it('does nothing if it is on a node that cannot be expanded', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(Number.POSITIVE_INFINITY); await waitForRenderedTreeNodeCount(shadowRoot, NODE_COUNT_BASIC_DATA_FULLY_EXPANDED); const chromeNode = getVisibleTreeNodeByText(shadowRoot, 'Chrome'); dispatchClickEvent(chromeNode); dispatchKeyDownEvent(chromeNode, {key: 'ArrowRight', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), chromeNode, ); }); it('expands the node if on an expandable node that is closed and does not move focus', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); dispatchClickEvent(officeNode); dispatchKeyDownEvent(officeNode, {key: 'ArrowRight', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), officeNode, ); assert.strictEqual(officeNode.getAttribute('aria-expanded'), 'true'); }); it('moves focus into the child if pressed on an expanded node', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); dispatchClickEvent(officeNode); // Press once to expand, twice to navigate in to the first child dispatchKeyDownEvent(officeNode, {key: 'ArrowRight', bubbles: true}); await RenderCoordinator.done(); dispatchKeyDownEvent(officeNode, {key: 'ArrowRight', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'Europe'), ); }); }); describe('pressing the LEFT arrow', () => { it('closes the node if the focused node is expanded', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(Number.POSITIVE_INFINITY); await RenderCoordinator.done(); const europeNode = getVisibleTreeNodeByText(shadowRoot, 'Europe'); dispatchClickEvent(europeNode); dispatchKeyDownEvent(europeNode, {key: 'ArrowLeft', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'Europe'), ); const visibleTree = visibleNodesToTree(shadowRoot); // The tree below "Europe" is hidden as the left arrow press closed that node. assert.deepEqual(visibleTree, [ { renderedKey: 'Offices', children: [{ renderedKey: 'Europe', }], }, { renderedKey: 'Products', children: [ { renderedKey: 'Chrome', }, { renderedKey: 'YouTube', }, { renderedKey: 'Drive', }, { renderedKey: 'Calendar', }, ], }, ]); }); it('moves to the parent node if the current node is not expanded or unexpandable', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(Number.POSITIVE_INFINITY); await waitForRenderedTreeNodeCount(shadowRoot, NODE_COUNT_BASIC_DATA_FULLY_EXPANDED); await RenderCoordinator.done(); const berlinNode = getVisibleTreeNodeByText(shadowRoot, 'BER'); dispatchClickEvent(berlinNode); dispatchKeyDownEvent(berlinNode, {key: 'ArrowLeft', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'Germany'), ); }); it('does nothing when called on a root node', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const productsNode = getVisibleTreeNodeByText(shadowRoot, 'Products'); dispatchClickEvent(productsNode); dispatchKeyDownEvent(productsNode, {key: 'ArrowLeft', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'Products'), ); }); }); describe('pressing the DOWN arrow', () => { it('moves down to the next sibling if the node is not expanded', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); dispatchClickEvent(officeNode); dispatchKeyDownEvent(officeNode, {key: 'ArrowDown', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'Products'), ); }); it('does not move if it is the last sibling and there are no parent siblings', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const productsNode = getVisibleTreeNodeByText(shadowRoot, 'Products'); dispatchClickEvent(productsNode); dispatchKeyDownEvent(productsNode, {key: 'ArrowDown', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'Products'), ); }); it('moves down to the first child of the node if it is expanded', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(); await RenderCoordinator.done(); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); dispatchClickEvent(officeNode); dispatchKeyDownEvent(officeNode, {key: 'ArrowDown', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'Europe'), ); }); it('moves to its parent\'s sibling if it is the last child', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(); await waitForRenderedTreeNodeCount(shadowRoot, NODE_COUNT_BASIC_DATA_DEFAULT_EXPANDED); const lonNode = getVisibleTreeNodeByText(shadowRoot, 'LON'); dispatchClickEvent(lonNode); dispatchKeyDownEvent(lonNode, {key: 'ArrowDown', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'Germany'), ); }); it('is able to navigate high up the tree to the correct next parent\'s sibling', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(Number.POSITIVE_INFINITY); await waitForRenderedTreeNodeCount(shadowRoot, NODE_COUNT_BASIC_DATA_FULLY_EXPANDED); const berNode = getVisibleTreeNodeByText(shadowRoot, 'BER'); dispatchClickEvent(berNode); dispatchKeyDownEvent(berNode, {key: 'ArrowDown', bubbles: true}); await RenderCoordinator.done(); assert.strictEqual( getFocusableTreeNode(shadowRoot), getVisibleTreeNodeByText(shadowRoot, 'Products'), ); }); }); }); // Note: all aria-* positional labels are 1 indexed, not 0 indexed. describe('aria-* labels', () => { it('adds correct aria-level labels', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(Number.POSITIVE_INFINITY); await waitForRenderedTreeNodeCount(shadowRoot, NODE_COUNT_BASIC_DATA_FULLY_EXPANDED); await RenderCoordinator.done(); const rootNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); assert.strictEqual(rootNode.getAttribute('aria-level'), '1'); const europeNode = getVisibleTreeNodeByText(shadowRoot, 'Europe'); assert.strictEqual(europeNode.getAttribute('aria-level'), '2'); const germanyNode = getVisibleTreeNodeByText(shadowRoot, 'Germany'); assert.strictEqual(germanyNode.getAttribute('aria-level'), '3'); const berlinNode = getVisibleTreeNodeByText(shadowRoot, 'BER'); assert.strictEqual(berlinNode.getAttribute('aria-level'), '4'); }); it('adds the correct setsize label to the root node', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const rootNode = getVisibleTreeNodeByText(shadowRoot, 'Products'); assert.strictEqual(rootNode.getAttribute('aria-setsize'), '2'); }); it('adds the correct setsize label to a deeply nested node', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(Number.POSITIVE_INFINITY); await waitForRenderedTreeNodeCount(shadowRoot, NODE_COUNT_BASIC_DATA_FULLY_EXPANDED); const europeKey = getVisibleTreeNodeByText(shadowRoot, 'Europe'); assert.strictEqual(europeKey.getAttribute('aria-setsize'), '1'); const germanyKey = getVisibleTreeNodeByText(shadowRoot, 'Germany'); // 2 because there are two keys at this level in the tree: UK & Germany assert.strictEqual(germanyKey.getAttribute('aria-setsize'), '2'); }); it('adds the posinset label to nodes correctly', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(Number.POSITIVE_INFINITY); await waitForRenderedTreeNodeCount(shadowRoot, NODE_COUNT_BASIC_DATA_FULLY_EXPANDED); const europeKey = getVisibleTreeNodeByText(shadowRoot, 'Europe'); assert.strictEqual(europeKey.getAttribute('aria-posinset'), '1'); const csgOfficeKey = getVisibleTreeNodeByText(shadowRoot, 'CSG'); // CSG is 2nd in the LON office list: 6PS, CSG, BEL assert.strictEqual(csgOfficeKey.getAttribute('aria-posinset'), '2'); }); it('sets aria-expanded to false on non-expanded nodes', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const rootNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); assert.strictEqual(rootNode.getAttribute('aria-expanded'), 'false'); }); it('sets aria-expanded to true on expanded nodes', async () => { const {shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); const rootNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); const arrowIcon = rootNode.querySelector<HTMLSpanElement>('.arrow-icon'); assert.instanceOf(arrowIcon, HTMLSpanElement); dispatchClickEvent(arrowIcon); await RenderCoordinator.done(); assert.strictEqual(rootNode.getAttribute('aria-expanded'), 'true'); }); it('does not set aria-expanded at all on leaf nodes', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await component.expandRecursively(Number.POSITIVE_INFINITY); await waitForRenderedTreeNodeCount(shadowRoot, NODE_COUNT_BASIC_DATA_FULLY_EXPANDED); const leafNodeCSGOffice = getVisibleTreeNodeByText(shadowRoot, 'CSG'); assert.isNull(leafNodeCSGOffice.getAttribute('aria-expanded')); }); }); describe('emitting events', () => { describe('itemselected event', () => { it('emits an event when the user clicks on the node', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await RenderCoordinator.done(); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); const treeItemSelectedEvent = getEventPromise<TreeOutline.TreeOutline.ItemSelectedEvent<string>>(component, 'itemselected'); dispatchClickEvent(officeNode); const event = await treeItemSelectedEvent; assert.deepEqual(event.data, {node: basicTreeData[0]}); }); it('emits an event when the user navigates to the node with their keyboard', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await RenderCoordinator.done(); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices'); dispatchClickEvent(officeNode); await RenderCoordinator.done(); dispatchKeyDownEvent(officeNode, {key: 'ArrowDown', bubbles: true}); const treeItemSelectedEvent = getEventPromise<TreeOutline.TreeOutline.ItemSelectedEvent<string>>(component, 'itemselected'); await RenderCoordinator.done(); const event = await treeItemSelectedEvent; assert.deepEqual(event.data, {node: basicTreeData[1]}); }); }); describe('itemmouseover', () => { it('emits an event when the user mouses over the element', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await RenderCoordinator.done(); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices').querySelector('.arrow-and-key-wrapper'); assert.instanceOf(officeNode, HTMLSpanElement); const itemMouseOverEvent = getEventPromise<TreeOutline.TreeOutline.ItemMouseOverEvent<string>>(component, 'itemmouseover'); dispatchMouseOverEvent(officeNode); const event = await itemMouseOverEvent; assert.deepEqual(event.data, {node: basicTreeData[0]}); }); }); describe('itemmouseout', () => { it('emits an event when the user mouses out of the element', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: basicTreeData, }); await RenderCoordinator.done(); const officeNode = getVisibleTreeNodeByText(shadowRoot, 'Offices').querySelector('.arrow-and-key-wrapper'); assert.instanceOf(officeNode, HTMLSpanElement); dispatchMouseOverEvent(officeNode); const itemMouseOutEvent = getEventPromise<TreeOutline.TreeOutline.ItemMouseOutEvent<string>>(component, 'itemmouseout'); dispatchMouseOutEvent(officeNode); const event = await itemMouseOutEvent; assert.deepEqual(event.data, {node: basicTreeData[0]}); }); }); }); describe('matching on id parameter', () => { it('expands the relevant part of the tree to reveal the given node', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: [nodeAustralia], }); // Expand to the node with the given ID, the actual data doesn't matter in this case. // This means you can search the tree, without having a reference to the specific tree data, // just as long as you know the id for whatever thing you are looking for. await component.expandToAndSelectTreeNode({treeNodeData: 'something else', id: 'gawler'}); await waitForRenderedTreeNodeCount(shadowRoot, 7); const visibleTree = visibleNodesToTree(shadowRoot); // The tree is expanded down to include "Gawler" but the rest of the tree is still collapsed. assert.deepEqual(visibleTree, [{ renderedKey: 'Australia', children: [ { renderedKey: 'SA', children: [ { renderedKey: 'Adelaide', children: [ {renderedKey: 'Toorak Gardens'}, {renderedKey: 'Woodville South'}, {renderedKey: 'Gawler'}, ], }, ], }, {renderedKey: 'NSW'}, ], }]); }); it('remembers nodes expanded state across node updates', async () => { const {component, shadowRoot} = await renderTreeOutline({ tree: [nodeAustralia], }); await component.expandToAndSelectTreeNode({treeNodeData: 'something else', id: 'gawler'}); // Update the node by replacing the root node. const newNodeAustralia = { treeNodeData: 'New Australia', id: 'australia', children: async () => [{ treeNodeData: 'Different SA', id: 'sa', children: async () => [{ treeNodeData: 'Phantom Adelaide', id: 'adelaide', children: async () => [{treeNodeData: 'Totally not Gawler', id: 'gawler'}, ], }, ], }, ], }; component.data = { tree: [newNodeAustralia], defaultRenderer: (node => html`${node.treeNodeData}`), }; await waitForRenderedTreeNodeCount(shadowRoot, 4); await RenderCoordinator.done(); const visibleTree = visibleNodesToTree(shadowRoot); // The tree should still be expanded down to the node with key `gawler`. assert.deepEqual(visibleTree, [{ renderedKey: 'New Australia', children: [ { renderedKey: 'Different SA', children: [ { renderedKey: 'Phantom Adelaide', children: [ {renderedKey: 'Totally not Gawler'}, ],