From 62cf94d5f92cdc9eb4d8780df3fede85f264d608 Mon Sep 17 00:00:00 2001 From: Rachel Bratt Tannenbaum Date: Wed, 11 Dec 2024 12:57:31 +0200 Subject: [PATCH] chore(tree-item, tree-view): decouples foundation (VIV-2014) (#2049) * import foundation code * import foundation code * import foundation code * formatting * chore: fixes for tree-item and view * chore: refactor * chore: uopdates tests * chore: updates tests * chore: formatting * chore: refactor * chore: updates tests --------- Co-authored-by: TaylorJ76 Co-authored-by: James Taylor <146064280+TaylorJ76@users.noreply.github.com> --- .../src/lib/tree-item/tree-item.spec.ts | 19 +- .../components/src/lib/tree-item/tree-item.ts | 181 ++++++++- .../src/lib/tree-view/tree-view.spec.ts | 254 ++++++++++++- .../components/src/lib/tree-view/tree-view.ts | 353 +++++++++++++++++- 4 files changed, 798 insertions(+), 9 deletions(-) diff --git a/libs/components/src/lib/tree-item/tree-item.spec.ts b/libs/components/src/lib/tree-item/tree-item.spec.ts index 4b14ee4319..0e95001607 100644 --- a/libs/components/src/lib/tree-item/tree-item.spec.ts +++ b/libs/components/src/lib/tree-item/tree-item.spec.ts @@ -43,9 +43,9 @@ describe('vwc-tree-item', () => { expect(treeItem1).toBeInstanceOf(TreeItem); expect(treeItem1.text).toBeUndefined(); expect(treeItem1.icon).toBeUndefined(); - expect(treeItem1.selected).toBeUndefined(); + expect(treeItem1.selected).toBeFalsy(); expect(treeItem1.expanded).toEqual(false); - expect(treeItem1.disabled).toBeUndefined(); + expect(treeItem1.disabled).toBeFalsy(); }); }); @@ -79,6 +79,11 @@ describe('vwc-tree-item', () => { }); }); + it('should include a role of `treeitem', async () => { + await elementUpdated(treeItem1); + expect(treeItem1.getAttribute('role')).toEqual('treeitem'); + }); + it('should set the `aria-selected` attribute with the `selected` value when provided', async () => { treeItem1.selected = true; await elementUpdated(treeItem1); @@ -144,6 +149,16 @@ describe('vwc-tree-item', () => { }); }); + describe('focus-item', () => { + it('should focus on the element', async () => { + expect(treeItem1.contains(document.activeElement)).toBeFalsy(); + TreeItem.focusItem(treeItem1); + await elementUpdated(treeItem1); + + expect(treeItem1.contains(document.activeElement)).toBeTruthy(); + }); + }); + describe('a11y', () => { it('should pass html a11y test', async () => { treeItem1.text = 'Tree item 1'; diff --git a/libs/components/src/lib/tree-item/tree-item.ts b/libs/components/src/lib/tree-item/tree-item.ts index d15f5cfbb9..b7714b3682 100644 --- a/libs/components/src/lib/tree-item/tree-item.ts +++ b/libs/components/src/lib/tree-item/tree-item.ts @@ -1,8 +1,21 @@ -import { TreeItem as FastTreeItem } from '@microsoft/fast-foundation'; -import { attr } from '@microsoft/fast-element'; +import { FoundationElement } from '@microsoft/fast-foundation'; +import { isHTMLElement } from '@microsoft/fast-web-utilities'; +import { attr, observable } from '@microsoft/fast-element'; import { applyMixins } from '../../shared/foundation/utilities/apply-mixins'; import { AffixIcon } from '../../shared/patterns/affix'; +/** + * check if the item is a tree item + * @public + * @remarks + * determines if element is an HTMLElement and if it has the role treeitem + */ +export function isTreeItemElement(el: Element): el is HTMLElement { + return ( + isHTMLElement(el) && (el.getAttribute('role') as string) === 'treeitem' + ); +} + /** * @public * @component tree-item @@ -11,7 +24,7 @@ import { AffixIcon } from '../../shared/patterns/affix'; * @event {CustomEvent} expanded-change - Fires a custom 'expanded-change' event when the expanded state changes * @event {CustomEvent} selected-change - Fires a custom 'selected-change' event when the selected state changes */ -export class TreeItem extends FastTreeItem { +export class TreeItem extends FoundationElement { /** * Indicates the text's text. * @@ -20,6 +33,168 @@ export class TreeItem extends FastTreeItem { * HTML Attribute: text */ @attr text?: string; + + /** + * When true, the control will be appear expanded by user interaction. + * @public + * @remarks + * HTML Attribute: expanded + */ + @attr({ mode: 'boolean' }) expanded = false; + expandedChanged(): void { + if (this.$fastController.isConnected) { + this.$emit('expanded-change', this); + } + } + + /** + * When true, the control will appear selected by user interaction. + * @public + * @remarks + * HTML Attribute: selected + */ + @attr({ + mode: 'boolean', + }) + selected = false; + selectedChanged(): void { + if (this.$fastController.isConnected) { + this.$emit('selected-change', this); + } + } + + /** + * When true, the control will be immutable by user interaction. See {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/disabled | disabled HTML attribute} for more information. + * @public + * @remarks + * HTML Attribute: disabled + */ + @attr({ mode: 'boolean' }) + disabled = false; + + /** + * Reference to the expand/collapse button + * + * @internal + */ + // @ts-expect-error Type is incorrectly non-optional + expandCollapseButton: HTMLDivElement; + + /** + * Whether the item is focusable + * + * @internal + */ + @observable + focusable = false; + + /** + * + * + * @internal + */ + @observable + // @ts-expect-error Type is incorrectly non-optional + childItems: HTMLElement[]; + + /** + * The slotted child tree items + * + * @internal + */ + @observable + items!: HTMLElement[]; + itemsChanged(): void { + if (this.$fastController.isConnected) { + this.items.forEach((node: HTMLElement) => { + if (isTreeItemElement(node)) { + // TODO: maybe not require it to be a TreeItem? + (node as TreeItem).nested = true; + } + }); + } + } + + /** + * Indicates if the tree item is nested + * + * @internal + */ + @observable + // @ts-expect-error Type is incorrectly non-optional + nested: boolean; + + /** + * + * + * @internal + */ + @observable + // @ts-expect-error Type is incorrectly non-optional + renderCollapsedChildren: boolean; + + /** + * Places document focus on a tree item + * + * @public + * @param el - the element to focus + */ + static focusItem(el: HTMLElement) { + (el as TreeItem).focusable = true; + el.focus(); + } + + /** + * Whether the tree is nested + * + * @public + */ + readonly isNestedItem = (): boolean => { + return isTreeItemElement(this.parentElement as Element); + }; + + /** + * Handle expand button click + * + * @internal + */ + handleExpandCollapseButtonClick = (e: MouseEvent): void => { + if (!this.disabled && !e.defaultPrevented) { + this.expanded = !this.expanded; + } + }; + + /** + * Handle focus events + * + * @internal + */ + handleFocus = (_e: FocusEvent): void => { + this.setAttribute('tabindex', '0'); + }; + + /** + * Handle blur events + * + * @internal + */ + handleBlur = (_e: FocusEvent): void => { + this.setAttribute('tabindex', '-1'); + }; + + /** + * Gets number of children + * + * @internal + */ + childItemLength(): number { + const treeChildren: HTMLElement[] = this.childItems.filter( + (item: HTMLElement) => { + return isTreeItemElement(item); + } + ); + return treeChildren.length; + } } export interface TreeItem extends AffixIcon {} diff --git a/libs/components/src/lib/tree-view/tree-view.spec.ts b/libs/components/src/lib/tree-view/tree-view.spec.ts index b0a9b7e648..55cea7d6c8 100644 --- a/libs/components/src/lib/tree-view/tree-view.spec.ts +++ b/libs/components/src/lib/tree-view/tree-view.spec.ts @@ -1,9 +1,19 @@ import { axe, elementUpdated, fixture } from '@vivid-nx/shared'; +import { + keyArrowDown, + keyArrowLeft, + keyArrowRight, + keyArrowUp, + keyBackspace, + keyEnd, + keyEnter, + keyHome, +} from '@microsoft/fast-web-utilities'; import type { TreeItem } from '../tree-item/tree-item'; import '../tree-item'; -import { TreeView } from './tree-view'; +import { getDisplayedNodes, TreeView } from './tree-view'; import '.'; const COMPONENT_TAG = 'vwc-tree-view'; @@ -47,11 +57,47 @@ describe('vwc-tree-view', () => { expect(element.contains(document.activeElement)).toBeTruthy(); - treeItem1.focus(); await elementUpdated(treeItem1); + expect(treeItem1.contains(document.activeElement)).toBeTruthy(); expect(element.contains(document.activeElement)).toBeTruthy(); }); + + it('should move focus out if theres no slotted items', async () => { + const emptyTreeView = (await fixture( + `<${COMPONENT_TAG}> + ` + )) as TreeView; + await elementUpdated(emptyTreeView); + const slot = emptyTreeView.shadowRoot?.querySelector('slot'); + expect(slot?.childNodes.length).toBe(0); + expect(element.contains(document.activeElement)).toBeFalsy(); + }); + }); + + describe('tree-view blur', () => { + it('should set tabindex to 0', async () => { + const divEle = (await fixture( + `
+ <${COMPONENT_TAG}> + + + + +
` + )) as HTMLDivElement; + element = divEle.querySelector(COMPONENT_TAG) as TreeView; + const button = divEle.querySelector('button') as HTMLButtonElement; + await elementUpdated(element); + + element.focus(); + await elementUpdated(element); + + button.focus(); + await elementUpdated(element); + + expect(element.getAttribute('tabindex')).toBe('0'); + }); }); describe('tree-view click', () => { @@ -93,6 +139,33 @@ describe('vwc-tree-view', () => { expect(treeItem1.contains(document.activeElement)).toBeFalsy(); }); + it('should only allow one tree item to be selected at a time', async () => { + treeItem1.click(); + await elementUpdated(treeItem1); + await elementUpdated(element); + + expect(treeItem1.getAttribute('aria-selected')).toEqual('true'); + + treeItem2.click(); + await elementUpdated(element); + + expect(treeItem1.getAttribute('aria-selected')).toEqual('false'); + expect(treeItem2.getAttribute('aria-selected')).toEqual('true'); + }); + + it('should deselect a selected item when clicked', async () => { + treeItem1.click(); + await elementUpdated(treeItem1); + await elementUpdated(element); + + expect(treeItem1.getAttribute('aria-selected')).toEqual('true'); + + treeItem1.click(); + await elementUpdated(element); + + expect(treeItem1.getAttribute('aria-selected')).toEqual('false'); + }); + it('should dispatch selected-changed', async () => { const spy = jest.fn(); @@ -107,6 +180,183 @@ describe('vwc-tree-view', () => { expect(spy).toBeCalled(); }); + + it('should return true if the click comes from a element that is not a tree-item', async () => { + expect(element.handleClick({ target: {} } as Event)).toBe(true); + }); + }); + + describe('keyboard interactions', () => { + let treeItem1_1: TreeItem; + + beforeEach(async () => { + element = (await fixture( + `<${COMPONENT_TAG}> + + + + + ` + )) as TreeView; + await elementUpdated(element); + + treeItem1 = element.querySelector('#item1') as TreeItem; + treeItem2 = element.querySelector('#item2') as TreeItem; + treeItem1_1 = element.querySelector('#item1_1') as TreeItem; + + await elementUpdated(treeItem1); + await elementUpdated(treeItem2); + await elementUpdated(treeItem1_1); + element.focus(); + await elementUpdated(element); + await elementUpdated(treeItem1); + }); + + it('should shift focus to the next tree-item when the ArrowDown key is pressed', async () => { + expect(treeItem1.contains(document.activeElement)).toBeTruthy(); + + treeItem1.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowDown, bubbles: true }) + ); + await elementUpdated(element); + await elementUpdated(treeItem2); + + expect(treeItem2.contains(document.activeElement)).toBeTruthy(); + }); + + it('should shift focus to the last tree-item when the END key is pressed', async () => { + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyEnd })); + await elementUpdated(element); + await elementUpdated(treeItem2); + + expect(treeItem2.contains(document.activeElement)).toBeTruthy(); + }); + + it('should shift focus to the previous tree-item when the ArrowUp key is pressed', async () => { + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyEnd })); + await elementUpdated(element); + await elementUpdated(treeItem2); + treeItem2.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowUp, bubbles: true }) + ); + await elementUpdated(element); + await elementUpdated(treeItem1); + + expect(treeItem1.contains(document.activeElement)).toBeTruthy(); + }); + + it('should shift focus to the first tree-item when the Home key is pressed', async () => { + element.dispatchEvent(new KeyboardEvent('keydown', { key: keyEnd })); + await elementUpdated(element); + await elementUpdated(treeItem2); + treeItem2.dispatchEvent( + new KeyboardEvent('keydown', { key: keyHome, bubbles: true }) + ); + await elementUpdated(element); + await elementUpdated(treeItem1); + + expect(treeItem1.contains(document.activeElement)).toBeTruthy(); + }); + + it('should expand nested tree-items when the ArrowRight key is pressed', async () => { + treeItem1.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowRight, bubbles: true }) + ); + await elementUpdated(treeItem1); + + expect(treeItem1.getAttribute('aria-expanded')).toEqual('true'); + }); + + it('should shift focus to the first nested tree-item when the ArrowRight key is pressed a second time', async () => { + treeItem1.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowRight, bubbles: true }) + ); + await elementUpdated(treeItem1); + + treeItem1.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowRight, bubbles: true }) + ); + await elementUpdated(treeItem1_1); + + expect(treeItem1_1.contains(document.activeElement)).toBeTruthy(); + }); + + it('should collapse expanded tree-items when the ArrowLeft key is pressed', async () => { + treeItem1.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowRight, bubbles: true }) + ); + await elementUpdated(treeItem1); + + expect(treeItem1.getAttribute('aria-expanded')).toEqual('true'); + + treeItem1.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowLeft, bubbles: true }) + ); + await elementUpdated(treeItem1); + + expect(treeItem1.getAttribute('aria-expanded')).toEqual('false'); + }); + + it('should shift focus to the parent tree-item when the ArrowLeft key is pressed when focussed on the first nested tree-item', async () => { + treeItem1.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowRight, bubbles: true }) + ); + await elementUpdated(treeItem1); + + treeItem1.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowRight, bubbles: true }) + ); + await elementUpdated(treeItem1_1); + + treeItem1_1.dispatchEvent( + new KeyboardEvent('keydown', { key: keyArrowLeft, bubbles: true }) + ); + await elementUpdated(treeItem1); + + expect(treeItem1.contains(document.activeElement)).toBeTruthy(); + }); + + it('should mark the tree-item as selected when the Enter key is pressed', async () => { + treeItem1.dispatchEvent( + new KeyboardEvent('keydown', { key: keyEnter, bubbles: true }) + ); + await elementUpdated(treeItem1); + + expect(treeItem1.getAttribute('aria-selected')).toEqual('true'); + }); + + it('should return true if the key press is not one the trigger an action', async () => { + expect( + element.handleKeyDown({ + key: keyBackspace, + bubbles: true, + } as KeyboardEvent) + ).toBeTruthy(); + }); + + it('shouold return true if there are no tree-item supplied', async () => { + element = (await fixture( + `<${COMPONENT_TAG}>` + )) as TreeView; + await elementUpdated(element); + element.focus(); + await elementUpdated(element); + + expect( + element.handleKeyDown({ + key: keyArrowDown, + bubbles: true, + } as KeyboardEvent) + ).toBeTruthy(); + }); + }); + + describe('getDisplayedNodes', () => { + it('should return an empty array when supplied with an node that is not a HTML element', () => { + expect(getDisplayedNodes({} as any, '[selector="something"]')).toEqual( + [] + ); + }); }); describe('a11y', () => { diff --git a/libs/components/src/lib/tree-view/tree-view.ts b/libs/components/src/lib/tree-view/tree-view.ts index c33206bb61..1e10a72eca 100644 --- a/libs/components/src/lib/tree-view/tree-view.ts +++ b/libs/components/src/lib/tree-view/tree-view.ts @@ -1,8 +1,357 @@ -import { TreeView as FastTreeView } from '@microsoft/fast-foundation'; +import { FoundationElement } from '@microsoft/fast-foundation'; +import { attr, DOM, observable } from '@microsoft/fast-element'; +import { + // getDisplayedNodes, + isHTMLElement, + keyArrowDown, + keyArrowLeft, + keyArrowRight, + keyArrowUp, + keyEnd, + keyEnter, + keyHome, +} from '@microsoft/fast-web-utilities'; +import { isTreeItemElement, TreeItem } from '../tree-item/tree-item.js'; + +export function getDisplayedNodes( + rootNode: HTMLElement, + selector: string +): HTMLElement[] { + if (isHTMLElement(rootNode)) { + // get all tree-items + const nodes: HTMLElement[] = Array.from( + rootNode.querySelectorAll(selector) + ); + + // only include nested items if their parents are expanded + const visibleNodes: HTMLElement[] = nodes.filter((node: HTMLElement) => { + if (node.parentElement instanceof TreeItem) { + if (node.parentElement.getAttribute('aria-expanded') === 'true') + return true; + } else { + return true; + } + return false; + }); + return visibleNodes; + } + return []; +} /** * @public * @component tree-view * @slot - Default slot. */ -export class TreeView extends FastTreeView {} +export class TreeView extends FoundationElement { + /** + /** + * When true, the control will be appear expanded by user interaction. + * @public + * @remarks + * HTML Attribute: render-collapsed-nodes + */ + @attr({ attribute: 'render-collapsed-nodes' }) + // @ts-expect-error Type is incorrectly non-optional + renderCollapsedNodes: boolean; + + /** + * The currently selected tree item + * @public + */ + @observable + // @ts-expect-error Type is incorrectly non-optional + currentSelected: HTMLElement | TreeItem | null; + + /** + * Slotted children + * + * @internal + */ + @observable + // @ts-expect-error Type is incorrectly non-optional + slottedTreeItems: HTMLElement[]; + slottedTreeItemsChanged(): void { + if (this.$fastController.isConnected) { + // update for slotted children change + this.setItems(); + } + } + + /** + * The tree item that is designated to be in the tab queue. + * + * @internal + */ + currentFocused: HTMLElement | TreeItem | null = null; + + /** + * Handle focus events + * + * @internal + */ + handleFocus = (e: FocusEvent): void => { + if (this.slottedTreeItems.length > 0) { + if (e.target === this) { + if (this.currentFocused !== null) { + TreeItem.focusItem(this.currentFocused); + } + + return; + } + + if (this.contains(e.target as Node)) { + this.setAttribute('tabindex', '-1'); + this.currentFocused = e.target as HTMLElement; + } + } + }; + + /** + * Handle blur events + * + * @internal + */ + handleBlur = (e: FocusEvent): void => { + if ( + e.target instanceof HTMLElement && + (e.relatedTarget === null || !this.contains(e.relatedTarget as Node)) + ) { + this.setAttribute('tabindex', '0'); + } + }; + + /** + * ref to the tree item + * + * @internal + */ + treeView!: HTMLElement; + + private nested!: boolean; + + override connectedCallback(): void { + super.connectedCallback(); + this.setAttribute('tabindex', '0'); + DOM.queueUpdate(() => { + this.setItems(); + }); + } + + /** + * KeyDown handler + * + * @internal + */ + handleKeyDown = (e: KeyboardEvent): boolean | void => { + if (this.slottedTreeItems.length < 1) { + return true; + } + + if (!e.defaultPrevented) { + const treeItems: HTMLElement[] | void = this.getVisibleNodes(); + + switch (e.key) { + case keyHome: + if (treeItems.length) { + TreeItem.focusItem(treeItems[0]); + } + return; + case keyEnd: + if (treeItems.length) { + TreeItem.focusItem(treeItems[treeItems.length - 1]); + } + return; + case keyArrowLeft: + if (e.target && this.isFocusableElement(e.target as HTMLElement)) { + const item = e.target as HTMLElement; + + if ( + item instanceof TreeItem && + item.childItemLength() > 0 && + item.expanded + ) { + item.expanded = false; + } else if ( + item instanceof TreeItem && + item.parentElement instanceof TreeItem + ) { + TreeItem.focusItem(item.parentElement); + } + } + return false; + case keyArrowRight: + if (e.target && this.isFocusableElement(e.target as HTMLElement)) { + const item = e.target as HTMLElement; + if ( + item instanceof TreeItem && + item.childItemLength() > 0 && + !item.expanded + ) { + item.expanded = true; + } else if (item instanceof TreeItem && item.childItemLength() > 0) { + this.focusNextNode(1, e.target as TreeItem); + } + } + return; + case keyArrowDown: + if (e.target && this.isFocusableElement(e.target as HTMLElement)) { + this.focusNextNode(1, e.target as TreeItem); + } + return; + case keyArrowUp: + if (e.target && this.isFocusableElement(e.target as HTMLElement)) { + this.focusNextNode(-1, e.target as TreeItem); + } + return; + case keyEnter: + // In single-select trees where selection does not follow focus (see note below), + // the default action is typically to select the focused node. + this.handleClick(e as Event); + return; + } + } + + // don't prevent default if we took no action + return true; + }; + + /** + * Handles click events bubbling up + * + * @internal + */ + handleClick(e: Event): boolean | void { + if (!e.defaultPrevented) { + if ( + !(e.target instanceof Element) || + !isTreeItemElement(e.target as Element) + ) { + // not a tree item, ignore + return true; + } + + const item: TreeItem = e.target as TreeItem; + + if (!item.disabled) { + item.selected = !item.selected; + } + + return; + } + } + + /** + * Handles the selected-changed events bubbling up + * from child tree items + * + * @internal + */ + handleSelectedChange = (e: Event): boolean | void => { + if (!e.defaultPrevented) { + if ( + !(e.target instanceof Element) || + !isTreeItemElement(e.target as Element) + ) { + return true; + } + + const item: TreeItem = e.target as TreeItem; + + if (item.selected) { + if (this.currentSelected && this.currentSelected !== item) { + (this.currentSelected as TreeItem).selected = false; + } + // new selected item + this.currentSelected = item; + } else if (!item.selected && this.currentSelected === item) { + // selected item deselected + this.currentSelected = null; + } + + return; + } + }; + + /** + * Move focus to a tree item based on its offset from the provided item + */ + private focusNextNode(delta: number, item: TreeItem): void { + const visibleNodes: HTMLElement[] | void = this.getVisibleNodes(); + if (visibleNodes) { + const focusItem = visibleNodes[visibleNodes.indexOf(item) + delta]; + if (isHTMLElement(focusItem)) { + TreeItem.focusItem(focusItem); + } + } + } + + /** + * Updates the tree view when slottedTreeItems changes + */ + private setItems = (): void => { + // force single selection + // defaults to first one found + const selectedItem: HTMLElement | null = this.treeView.querySelector( + "[aria-selected='true']" + ); + this.currentSelected = selectedItem; + + // invalidate the current focused item if it is no longer valid + if (this.currentFocused === null || !this.contains(this.currentFocused)) { + this.currentFocused = this.getValidFocusableItem(); + } + + // toggle properties on child elements + this.nested = this.checkForNestedItems(); + + const treeItems: HTMLElement[] | void = this.getVisibleNodes(); + treeItems.forEach((node) => { + if (isTreeItemElement(node)) { + (node as TreeItem).nested = this.nested; + } + }); + }; + + /** + * checks if there are any nested tree items + */ + private getValidFocusableItem(): null | HTMLElement | TreeItem { + const treeItems = this.getVisibleNodes(); + // default to selected element if there is one + let focusIndex = treeItems.findIndex(this.isSelectedElement); + if (focusIndex === -1) { + // otherwise first focusable tree item + focusIndex = treeItems.findIndex(this.isFocusableElement); + } + if (focusIndex !== -1) { + return treeItems[focusIndex]; + } + + return null; + } + + /** + * checks if there are any nested tree items + */ + private checkForNestedItems(): boolean { + return this.slottedTreeItems.some((node: HTMLElement) => { + return isTreeItemElement(node) && node.querySelector("[role='treeitem']"); + }); + } + + /** + * check if the item is focusable + */ + private isFocusableElement = (el: Element): el is HTMLElement => { + return isTreeItemElement(el); + }; + + private isSelectedElement = (el: HTMLElement): boolean => { + return (el as any).selected; + }; + + private getVisibleNodes(): HTMLElement[] { + return getDisplayedNodes(this, "[role='treeitem']"); + } +}