From 2e784ac78a36fdd77f90fecb41265f6663d77cf5 Mon Sep 17 00:00:00 2001 From: JC Franco Date: Wed, 23 Aug 2023 02:09:46 -0700 Subject: [PATCH 1/2] fix(tree): improve keyboard navigation --- .../src/components/tree-item/tree-item.e2e.ts | 58 +- .../src/components/tree-item/tree-item.tsx | 40 +- .../src/components/tree/tree.e2e.ts | 617 +++++++++--------- .../src/components/tree/tree.tsx | 104 ++- .../src/components/tree/utils.ts | 32 +- 5 files changed, 403 insertions(+), 448 deletions(-) diff --git a/packages/calcite-components/src/components/tree-item/tree-item.e2e.ts b/packages/calcite-components/src/components/tree-item/tree-item.e2e.ts index c0d3a7bf04f..1f7e070d9aa 100644 --- a/packages/calcite-components/src/components/tree-item/tree-item.e2e.ts +++ b/packages/calcite-components/src/components/tree-item/tree-item.e2e.ts @@ -285,8 +285,7 @@ describe("calcite-tree-item", () => { const item = await page.find("#newbie"); expect(item).toEqualAttribute("aria-hidden", "false"); - expect(item).not.toHaveAttribute("calcite-hydrated-hidden"); - expect(item.tabIndex).toBe(0); + expect(item.tabIndex).toBe(-1); // items are programmatically focused }); }); @@ -362,61 +361,6 @@ describe("calcite-tree-item", () => { expect(isVisible).toBe(true); }); - it("right arrow key expands subtree and left arrow collapses it", async () => { - const page = await newE2EPage({ - html: ` - - - Cables - - XLR Cable - Instrument Cable - - - - `, - }); - - await page.keyboard.press("Tab"); - - expect(await page.evaluate(() => document.activeElement.id)).toBe("cables"); - expect(await page.evaluate(() => (document.activeElement as HTMLCalciteTreeItemElement).expanded)).toBe(false); - - await page.keyboard.press("ArrowRight"); - - expect(await page.evaluate(() => document.activeElement.id)).toBe("cables"); - expect(await page.evaluate(() => (document.activeElement as HTMLCalciteTreeItemElement).expanded)).toBe(true); - - await page.keyboard.press("ArrowLeft"); - - expect(await page.evaluate(() => document.activeElement.id)).toBe("cables"); - expect(await page.evaluate(() => (document.activeElement as HTMLCalciteTreeItemElement).expanded)).toBe(false); - }); - - it("right arrow key focuses first item in expanded subtree", async () => { - const page = await newE2EPage({ - html: ` - - - Cables - - XLR Cable - Instrument Cable - - - - `, - }); - - await page.keyboard.press("Tab"); - - expect(await page.evaluate(() => document.activeElement.id)).toBe("cables"); - - await page.keyboard.press("ArrowRight"); - - expect(await page.evaluate(() => document.activeElement.id)).toBe("xlr"); - }); - it("displaying an expanded item is visible", async () => { const page = await newE2EPage(); await page.setContent( diff --git a/packages/calcite-components/src/components/tree-item/tree-item.tsx b/packages/calcite-components/src/components/tree-item/tree-item.tsx index 30791bb652b..3b0cb8dfbc8 100644 --- a/packages/calcite-components/src/components/tree-item/tree-item.tsx +++ b/packages/calcite-components/src/components/tree-item/tree-item.tsx @@ -12,11 +12,10 @@ import { Watch, } from "@stencil/core"; import { - slotChangeHasAssignedElement, filterDirectChildren, getElementDir, getSlotted, - nodeListToArray, + slotChangeHasAssignedElement, toAriaBoolean, } from "../../utils/dom"; import { @@ -186,7 +185,10 @@ export class TreeItem implements ConditionalSlotComponent, InteractiveComponent } componentDidRender(): void { - updateHostInteraction(this, () => this.parentExpanded || this.depth === 1); + updateHostInteraction( + this, + () => false // programmatically focusable + ); } //-------------------------------------------------------------------------- @@ -358,8 +360,6 @@ export class TreeItem implements ConditionalSlotComponent, InteractiveComponent @Listen("keydown") keyDownHandler(event: KeyboardEvent): void { - let root; - if (this.isActionEndEvent(event)) { return; } @@ -382,7 +382,7 @@ export class TreeItem implements ConditionalSlotComponent, InteractiveComponent return; } // activates a node, i.e., performs its default action. For parent nodes, one possible default action is to open or close the node. In single-select trees where selection does not follow focus (see note below), the default action is typically to select the focused node. - const link = nodeListToArray(this.el.children).find((el) => + const link = Array.from(this.el.children).find((el) => el.matches("a") ) as HTMLAnchorElement; @@ -398,33 +398,8 @@ export class TreeItem implements ConditionalSlotComponent, InteractiveComponent updateItem: true, }); } - event.preventDefault(); - break; - case "Home": - root = this.el.closest("calcite-tree:not([child])") as HTMLCalciteTreeElement; - - const firstNode = root.querySelector("calcite-tree-item"); - - firstNode?.focus(); - - break; - case "End": - root = this.el.closest("calcite-tree:not([child])"); - - let currentNode = root.children[root.children.length - 1]; // last child - let currentTree = nodeListToArray(currentNode.children).find((el) => - el.matches("calcite-tree") - ); - while (currentTree) { - currentNode = currentTree.children[root.children.length - 1]; - currentTree = nodeListToArray(currentNode.children).find((el) => - el.matches("calcite-tree") - ); - } - currentNode?.focus(); - break; - } + } } //-------------------------------------------------------------------------- @@ -533,3 +508,4 @@ export class TreeItem implements ConditionalSlotComponent, InteractiveComponent this.hasEndActions = slotChangeHasAssignedElement(event); }; } + diff --git a/packages/calcite-components/src/components/tree/tree.e2e.ts b/packages/calcite-components/src/components/tree/tree.e2e.ts index db976677981..a0139e067a2 100644 --- a/packages/calcite-components/src/components/tree/tree.e2e.ts +++ b/packages/calcite-components/src/components/tree/tree.e2e.ts @@ -1,8 +1,9 @@ -import { E2EPage, newE2EPage } from "@stencil/core/testing"; +import { newE2EPage } from "@stencil/core/testing"; import { html } from "../../../support/formatting"; import { accessible, defaults, hidden, renders } from "../../tests/commonTests"; import { CSS } from "../tree-item/resources"; import SpyInstance = jest.SpyInstance; +import { getFocusedElementProp } from "../../tests/utils"; describe("calcite-tree", () => { describe("renders", () => { @@ -549,17 +550,17 @@ describe("calcite-tree", () => { }); describe("keyboard support", () => { - it("should allow space keydown events to propagate outside the root tree", async () => { + it("does not stop propagation of handled keyboard events", async () => { const page = await newE2EPage({ - html: html`
+ html: html` - + One - + Child 1 - + Grandchild 1 @@ -567,396 +568,243 @@ describe("calcite-tree", () => { -
`, + `, }); - const container = await page.find("#container"); - const grandchild = await page.find("#grandchild-one"); - const keydownSpy = await container.spyOnEvent("keydown"); + const keyDownSpy = await page.spyOnEvent("keydown"); + const item = await page.find("#middle-item"); + await item.focus(); - expect(keydownSpy).toHaveReceivedEventTimes(0); + expect(keyDownSpy).toHaveReceivedEventTimes(0); - await grandchild.focus(); - await page.keyboard.press("Space"); + await page.keyboard.press("Space"); // open + await page.keyboard.press("Enter"); // close + await page.keyboard.press("ArrowRight"); // open + await page.keyboard.press("ArrowLeft"); // close + await page.keyboard.press("Home"); + await page.keyboard.press("End"); + await page.keyboard.press("Tab"); - expect(keydownSpy).toHaveReceivedEventTimes(1); + expect(keyDownSpy).toHaveReceivedEventTimes(7); }); - it("should allow enter keydown events to propagate outside the root tree", async () => { - const page = await newE2EPage({ - html: html`
- - - One - - - Child 1 - - - Grandchild 1 - - - - + it("supports navigating the entire tree structure", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + Root Item 1 + + + Parent + + + Child - -
`, - }); - - const container = await page.find("#container"); - const grandchild = await page.find("#grandchild-one"); - const keydownSpy = await container.spyOnEvent("keydown"); - - expect(keydownSpy).toHaveReceivedEventTimes(0); - - await grandchild.focus(); - await page.keyboard.press("Enter"); - - expect(keydownSpy).toHaveReceivedEventTimes(1); - }); - - it.skip("should allow ArrowRight and ArrowLeft keydown events to propagate outside the root tree", async () => { - const page = await newE2EPage({ - html: html`
- - - One + + Child 2 - - Child 1 - - - Grandchild 1 - - + + Grandchild - - - -
`, - }); - - const container = await page.find("#container"); - const one = await page.find("#one"); - const keydownSpy = await container.spyOnEvent("keydown"); - - expect(keydownSpy).toHaveReceivedEventTimes(0); - - await one.focus(); - await page.keyboard.press("ArrowRight"); - - expect(keydownSpy).toHaveReceivedEventTimes(1); - - await page.keyboard.press("ArrowRight"); - - expect(keydownSpy).toHaveReceivedEventTimes(2); - - await page.keyboard.press("ArrowRight"); - - expect(keydownSpy).toHaveReceivedEventTimes(3); - - await page.keyboard.press("ArrowLeft"); - - expect(keydownSpy).toHaveReceivedEventTimes(4); - - await page.keyboard.press("ArrowLeft"); - - expect(keydownSpy).toHaveReceivedEventTimes(5); - - await page.keyboard.press("ArrowLeft"); - - expect(keydownSpy).toHaveReceivedEventTimes(6); - }); - - async function getActiveElementId(page: E2EPage): Promise { - return page.evaluate(() => document.activeElement.id); - } - - it("ArrowRight and ArrowLeft keys expand and collapse nested trees", async () => { - const parent = "parent"; - const child = "child"; - const grandchild = "grandchild"; - - const page = await newE2EPage({ - html: html`
- - - Parent - - - Child - - - Grandchild - - + + Grandchild 2 + + Child 3 + -
`, - }); - - const parentEl = await page.find(`#${parent}`); - const childEl = await page.find(`#${child}`); + + + Root Item 3 + + `); - expect(await parentEl.getProperty("expanded")).toBe(false); - expect(await childEl.getProperty("expanded")).toBe(false); + const root = await page.find("#root"); + const parent = await page.find("#parent"); + const child2 = await page.find("#child2"); - await parentEl.focus(); - await page.keyboard.press("ArrowRight"); + await root.focus(); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual(parent); - expect(await parentEl.getProperty("expanded")).toBe(true); - expect(await childEl.getProperty("expanded")).toBe(false); + expect(await getFocusedElementProp(page, "id")).toEqual("root-item-1"); + expect(await parent.getProperty("expanded")).toBe(false); + expect(await child2.getProperty("expanded")).toBe(false); - await page.keyboard.press("ArrowRight"); + await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual(child); - expect(await parentEl.getProperty("expanded")).toBe(true); - expect(await childEl.getProperty("expanded")).toBe(false); + expect(await getFocusedElementProp(page, "id")).toEqual("parent"); + expect(await parent.getProperty("expanded")).toBe(false); + expect(await child2.getProperty("expanded")).toBe(false); - await page.keyboard.press("ArrowRight"); + await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual(child); - expect(await parentEl.getProperty("expanded")).toBe(true); - expect(await childEl.getProperty("expanded")).toBe(true); + expect(await getFocusedElementProp(page, "id")).toEqual("root-item-3"); + expect(await parent.getProperty("expanded")).toBe(false); + expect(await child2.getProperty("expanded")).toBe(false); - await page.keyboard.press("ArrowRight"); + await page.keyboard.press("Home"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual(grandchild); - expect(await parentEl.getProperty("expanded")).toBe(true); - expect(await childEl.getProperty("expanded")).toBe(true); + expect(await getFocusedElementProp(page, "id")).toEqual("root-item-1"); + expect(await parent.getProperty("expanded")).toBe(false); + expect(await child2.getProperty("expanded")).toBe(false); - await page.keyboard.press("ArrowRight"); + await page.keyboard.press("End"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual(grandchild); - expect(await parentEl.getProperty("expanded")).toBe(true); - expect(await childEl.getProperty("expanded")).toBe(true); + expect(await getFocusedElementProp(page, "id")).toEqual("root-item-3"); + expect(await parent.getProperty("expanded")).toBe(false); + expect(await child2.getProperty("expanded")).toBe(false); - await page.keyboard.press("ArrowRight"); + await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual(grandchild); - expect(await parentEl.getProperty("expanded")).toBe(true); - expect(await childEl.getProperty("expanded")).toBe(true); + expect(await getFocusedElementProp(page, "id")).toEqual("parent"); + expect(await parent.getProperty("expanded")).toBe(false); + expect(await child2.getProperty("expanded")).toBe(false); - await page.keyboard.press("ArrowLeft"); + await page.keyboard.press("ArrowRight"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual(child); - expect(await parentEl.getProperty("expanded")).toBe(true); - expect(await childEl.getProperty("expanded")).toBe(true); + expect(await getFocusedElementProp(page, "id")).toEqual("parent"); + expect(await parent.getProperty("expanded")).toBe(true); + expect(await child2.getProperty("expanded")).toBe(false); - await page.keyboard.press("ArrowLeft"); + await page.keyboard.press("ArrowRight"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual(child); - expect(await parentEl.getProperty("expanded")).toBe(true); - expect(await childEl.getProperty("expanded")).toBe(false); + expect(await getFocusedElementProp(page, "id")).toEqual("child"); + expect(await parent.getProperty("expanded")).toBe(true); + expect(await child2.getProperty("expanded")).toBe(false); - await page.keyboard.press("ArrowLeft"); + await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual(parent); - expect(await parentEl.getProperty("expanded")).toBe(true); - expect(await childEl.getProperty("expanded")).toBe(false); + expect(await getFocusedElementProp(page, "id")).toEqual("child2"); + expect(await parent.getProperty("expanded")).toBe(true); + expect(await child2.getProperty("expanded")).toBe(false); - await page.keyboard.press("ArrowLeft"); + await page.keyboard.press("ArrowRight"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual(parent); - expect(await parentEl.getProperty("expanded")).toBe(false); - expect(await childEl.getProperty("expanded")).toBe(false); - }); - - it("ArrowUp and ArrowDown keys move focus between adjacent tree items at all 3 levels of depth and allow keydown events to propagate outside the tree root", async () => { - const page = await newE2EPage({ - html: html`
- - - Root Item 1 - - - Parent - - - Child - - - Child 2 - - - Grandchild - - - Grandchild 2 - - - - - Child 3 - - - - - Root Item 3 - - -
`, - }); - - const container = await page.find("#container"); - const root = await page.find("#root"); - const keydownSpy = await container.spyOnEvent("keydown"); - - expect(keydownSpy).toHaveReceivedEventTimes(0); + expect(await getFocusedElementProp(page, "id")).toEqual("child2"); + expect(await parent.getProperty("expanded")).toBe(true); + expect(await child2.getProperty("expanded")).toBe(true); - await root.focus(); + await page.keyboard.press("ArrowRight"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("root-item-1"); + expect(await getFocusedElementProp(page, "id")).toEqual("grandchild"); + expect(await parent.getProperty("expanded")).toBe(true); + expect(await child2.getProperty("expanded")).toBe(true); await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("parent"); - expect(keydownSpy).toHaveReceivedEventTimes(1); + expect(await getFocusedElementProp(page, "id")).toEqual("grandchild2"); + expect(await parent.getProperty("expanded")).toBe(true); + expect(await child2.getProperty("expanded")).toBe(true); - await page.keyboard.press("ArrowRight"); + await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("child"); - expect(keydownSpy).toHaveReceivedEventTimes(2); + expect(await getFocusedElementProp(page, "id")).toEqual("grandchild"); + expect(await parent.getProperty("expanded")).toBe(true); + expect(await child2.getProperty("expanded")).toBe(true); - await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowLeft"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("child2"); - expect(keydownSpy).toHaveReceivedEventTimes(3); + expect(await getFocusedElementProp(page, "id")).toEqual("child2"); + expect(await parent.getProperty("expanded")).toBe(true); + expect(await child2.getProperty("expanded")).toBe(true); - await page.keyboard.press("ArrowRight"); + await page.keyboard.press("ArrowLeft"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("grandchild"); - expect(keydownSpy).toHaveReceivedEventTimes(4); + expect(await getFocusedElementProp(page, "id")).toEqual("child2"); + expect(await parent.getProperty("expanded")).toBe(true); + expect(await child2.getProperty("expanded")).toBe(false); await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("grandchild2"); - expect(keydownSpy).toHaveReceivedEventTimes(5); + expect(await getFocusedElementProp(page, "id")).toEqual("child3"); + expect(await parent.getProperty("expanded")).toBe(true); + expect(await child2.getProperty("expanded")).toBe(false); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("grandchild"); - expect(keydownSpy).toHaveReceivedEventTimes(6); + expect(await getFocusedElementProp(page, "id")).toEqual("child2"); + expect(await parent.getProperty("expanded")).toBe(true); + expect(await child2.getProperty("expanded")).toBe(false); - await page.keyboard.press("ArrowLeft"); - await page.keyboard.press("ArrowDown"); + await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("child3"); - expect(keydownSpy).toHaveReceivedEventTimes(8); + expect(await getFocusedElementProp(page, "id")).toEqual("child"); + expect(await parent.getProperty("expanded")).toBe(true); + expect(await child2.getProperty("expanded")).toBe(false); - await page.keyboard.press("ArrowUp"); + await page.keyboard.press("ArrowLeft"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("child2"); - expect(keydownSpy).toHaveReceivedEventTimes(9); + expect(await getFocusedElementProp(page, "id")).toEqual("parent"); + expect(await parent.getProperty("expanded")).toBe(true); + expect(await child2.getProperty("expanded")).toBe(false); - await page.keyboard.press("ArrowUp"); + await page.keyboard.press("ArrowLeft"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("child"); - expect(keydownSpy).toHaveReceivedEventTimes(10); + expect(await getFocusedElementProp(page, "id")).toEqual("parent"); + expect(await parent.getProperty("expanded")).toBe(false); + expect(await child2.getProperty("expanded")).toBe(false); - await page.keyboard.press("ArrowLeft"); await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("root-item-3"); - expect(keydownSpy).toHaveReceivedEventTimes(12); + expect(await getFocusedElementProp(page, "id")).toEqual("root-item-3"); + expect(await parent.getProperty("expanded")).toBe(false); + expect(await child2.getProperty("expanded")).toBe(false); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("parent"); - expect(keydownSpy).toHaveReceivedEventTimes(13); + expect(await getFocusedElementProp(page, "id")).toEqual("parent"); + expect(await parent.getProperty("expanded")).toBe(false); + expect(await child2.getProperty("expanded")).toBe(false); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("root-item-1"); - expect(keydownSpy).toHaveReceivedEventTimes(14); - }); - - it("does prevent space/enter keyboard event on actions with selectionMode of single", async () => { - const page = await newE2EPage(); - await page.setContent(html`
- - - - - -
`); - - const container = await page.find("#container"); - const button = await page.find("button"); - const keydownSpy = await container.spyOnEvent("keydown"); - - expect(keydownSpy).toHaveReceivedEventTimes(0); - - await button.focus(); - await page.keyboard.press("Enter"); - - expect(keydownSpy).toHaveReceivedEventTimes(1); - expect(keydownSpy.lastEvent.defaultPrevented).toBe(true); - - await page.keyboard.press("Space"); - - expect(keydownSpy).toHaveReceivedEventTimes(2); - expect(keydownSpy.lastEvent.defaultPrevented).toBe(true); - }); - - it("does not prevent space/enter keyboard event on actions with selectionMode of none", async () => { - const page = await newE2EPage(); - await page.setContent(html`
- - - - - -
`); - - const container = await page.find("#container"); - const button = await page.find("button"); - const keydownSpy = await container.spyOnEvent("keydown"); - - expect(keydownSpy).toHaveReceivedEventTimes(0); + expect(await getFocusedElementProp(page, "id")).toEqual("root-item-1"); + expect(await parent.getProperty("expanded")).toBe(false); + expect(await child2.getProperty("expanded")).toBe(false); - await button.focus(); - await page.keyboard.press("Enter"); + await page.keyboard.press("End"); + await page.waitForChanges(); - expect(keydownSpy).toHaveReceivedEventTimes(1); - expect(keydownSpy.lastEvent.defaultPrevented).toBe(false); + expect(await getFocusedElementProp(page, "id")).toEqual("root-item-3"); + expect(await parent.getProperty("expanded")).toBe(false); + expect(await child2.getProperty("expanded")).toBe(false); - await page.keyboard.press("Space"); + await page.keyboard.press("Home"); + await page.waitForChanges(); - expect(keydownSpy).toHaveReceivedEventTimes(2); - expect(keydownSpy.lastEvent.defaultPrevented).toBe(false); + expect(await getFocusedElementProp(page, "id")).toEqual("root-item-1"); + expect(await parent.getProperty("expanded")).toBe(false); + expect(await child2.getProperty("expanded")).toBe(false); }); it("honors disabled items when navigating the tree", async () => { const page = await newE2EPage(); await page.setContent( - html` + html` Child 1 @@ -1003,60 +851,211 @@ describe("calcite-tree", () => { ` ); - await page.click("#child-1"); + const root = await page.find("#child-1"); + const child3 = await page.find("#child-3"); + const grandchild2 = await page.find("#grandchild-2"); + + await root.focus(); - expect(await getActiveElementId(page)).toEqual("child-1"); + expect(await getFocusedElementProp(page, "id")).toEqual("child-1"); + expect(await child3.getProperty("expanded")).toBe(false); + expect(await grandchild2.getProperty("expanded")).toBe(false); await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("child-3"); + expect(await getFocusedElementProp(page, "id")).toEqual("child-3"); + expect(await child3.getProperty("expanded")).toBe(false); + expect(await grandchild2.getProperty("expanded")).toBe(false); await page.keyboard.press("ArrowRight"); await page.waitForChanges(); + + expect(await getFocusedElementProp(page, "id")).toEqual("child-3"); + expect(await child3.getProperty("expanded")).toBe(true); + expect(await grandchild2.getProperty("expanded")).toBe(false); + await page.keyboard.press("ArrowRight"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("grandchild-2"); + expect(await getFocusedElementProp(page, "id")).toEqual("grandchild-2"); + expect(await child3.getProperty("expanded")).toBe(true); + expect(await grandchild2.getProperty("expanded")).toBe(false); await page.keyboard.press("ArrowRight"); await page.waitForChanges(); + + expect(await getFocusedElementProp(page, "id")).toEqual("grandchild-2"); + expect(await child3.getProperty("expanded")).toBe(true); + expect(await grandchild2.getProperty("expanded")).toBe(true); + await page.keyboard.press("ArrowRight"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("great-grandchild-1"); + expect(await getFocusedElementProp(page, "id")).toEqual("great-grandchild-1"); + expect(await child3.getProperty("expanded")).toBe(true); + expect(await grandchild2.getProperty("expanded")).toBe(true); await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("great-grandchild-3"); + expect(await getFocusedElementProp(page, "id")).toEqual("great-grandchild-3"); + expect(await child3.getProperty("expanded")).toBe(true); + expect(await grandchild2.getProperty("expanded")).toBe(true); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("great-grandchild-1"); + expect(await getFocusedElementProp(page, "id")).toEqual("great-grandchild-1"); + expect(await child3.getProperty("expanded")).toBe(true); + expect(await grandchild2.getProperty("expanded")).toBe(true); + + await page.keyboard.press("End"); + await page.waitForChanges(); + + expect(await getFocusedElementProp(page, "id")).toEqual("child-4"); + expect(await child3.getProperty("expanded")).toBe(true); + expect(await grandchild2.getProperty("expanded")).toBe(true); + + await page.keyboard.press("Home"); + await page.waitForChanges(); + + expect(await getFocusedElementProp(page, "id")).toEqual("child-1"); + expect(await child3.getProperty("expanded")).toBe(true); + expect(await grandchild2.getProperty("expanded")).toBe(true); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(await getFocusedElementProp(page, "id")).toEqual("child-3"); + expect(await child3.getProperty("expanded")).toBe(true); + expect(await grandchild2.getProperty("expanded")).toBe(true); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(await getFocusedElementProp(page, "id")).toEqual("grandchild-2"); + expect(await child3.getProperty("expanded")).toBe(true); + expect(await grandchild2.getProperty("expanded")).toBe(true); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(await getFocusedElementProp(page, "id")).toEqual("great-grandchild-1"); + expect(await child3.getProperty("expanded")).toBe(true); + expect(await grandchild2.getProperty("expanded")).toBe(true); await page.keyboard.press("ArrowLeft"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("grandchild-2"); + expect(await getFocusedElementProp(page, "id")).toEqual("grandchild-2"); + expect(await child3.getProperty("expanded")).toBe(true); + expect(await grandchild2.getProperty("expanded")).toBe(true); - await page.keyboard.press("ArrowUp"); + await page.keyboard.press("ArrowLeft"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("grandchild-2"); + expect(await getFocusedElementProp(page, "id")).toEqual("grandchild-2"); + expect(await child3.getProperty("expanded")).toBe(true); + expect(await grandchild2.getProperty("expanded")).toBe(false); await page.keyboard.press("ArrowLeft"); await page.waitForChanges(); + + expect(await getFocusedElementProp(page, "id")).toEqual("child-3"); + expect(await child3.getProperty("expanded")).toBe(true); + expect(await grandchild2.getProperty("expanded")).toBe(false); + await page.keyboard.press("ArrowLeft"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("child-3"); + expect(await getFocusedElementProp(page, "id")).toEqual("child-3"); + expect(await child3.getProperty("expanded")).toBe(false); + expect(await grandchild2.getProperty("expanded")).toBe(false); + + await page.keyboard.press("Home"); + await page.waitForChanges(); + + expect(await getFocusedElementProp(page, "id")).toEqual("child-1"); + expect(await child3.getProperty("expanded")).toBe(false); + expect(await grandchild2.getProperty("expanded")).toBe(false); + + await page.keyboard.press("End"); + await page.waitForChanges(); + + expect(await getFocusedElementProp(page, "id")).toEqual("child-4"); + expect(await child3.getProperty("expanded")).toBe(false); + expect(await grandchild2.getProperty("expanded")).toBe(false); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await getActiveElementId(page)).toEqual("child-1"); + expect(await getFocusedElementProp(page, "id")).toEqual("child-3"); + expect(await child3.getProperty("expanded")).toBe(false); + expect(await grandchild2.getProperty("expanded")).toBe(false); + + await page.keyboard.press("Home"); + await page.waitForChanges(); + + expect(await getFocusedElementProp(page, "id")).toEqual("child-1"); + expect(await child3.getProperty("expanded")).toBe(false); + expect(await grandchild2.getProperty("expanded")).toBe(false); + }); + + it("does prevent space/enter keyboard event on actions with selectionMode of single", async () => { + const page = await newE2EPage(); + await page.setContent(html`
+ + + + + +
`); + + const container = await page.find("#container"); + const button = await page.find("button"); + const keydownSpy = await container.spyOnEvent("keydown"); + + expect(keydownSpy).toHaveReceivedEventTimes(0); + + await button.focus(); + await page.keyboard.press("Enter"); + + expect(keydownSpy).toHaveReceivedEventTimes(1); + expect(keydownSpy.lastEvent.defaultPrevented).toBe(true); + + await page.keyboard.press("Space"); + + expect(keydownSpy).toHaveReceivedEventTimes(2); + expect(keydownSpy.lastEvent.defaultPrevented).toBe(true); + }); + + it("does not prevent space/enter keyboard event on actions with selectionMode of none", async () => { + const page = await newE2EPage(); + await page.setContent(html`
+ + + + + +
`); + + const container = await page.find("#container"); + const button = await page.find("button"); + const keydownSpy = await container.spyOnEvent("keydown"); + + expect(keydownSpy).toHaveReceivedEventTimes(0); + + await button.focus(); + await page.keyboard.press("Enter"); + + expect(keydownSpy).toHaveReceivedEventTimes(1); + expect(keydownSpy.lastEvent.defaultPrevented).toBe(false); + + await page.keyboard.press("Space"); + + expect(keydownSpy).toHaveReceivedEventTimes(2); + expect(keydownSpy.lastEvent.defaultPrevented).toBe(false); }); }); diff --git a/packages/calcite-components/src/components/tree/tree.tsx b/packages/calcite-components/src/components/tree/tree.tsx index bc603b8aa15..507eb4ee96a 100644 --- a/packages/calcite-components/src/components/tree/tree.tsx +++ b/packages/calcite-components/src/components/tree/tree.tsx @@ -9,10 +9,10 @@ import { Prop, VNode, } from "@stencil/core"; -import { focusElement, getRootNode, nodeListToArray } from "../../utils/dom"; +import { focusElement, nodeListToArray } from "../../utils/dom"; import { Scale, SelectionMode } from "../interfaces"; import { TreeItemSelectDetail } from "../tree-item/interfaces"; -import { getEnabledSiblingItem } from "./utils"; +import { getTraversableItems, isTreeItem } from "./utils"; /** * @slot - A slot for `calcite-tree-item` elements. @@ -85,6 +85,7 @@ export class Tree { this.selectionMode === "multiple" || this.selectionMode === "multichildren" ).toString() } + onKeyDown={this.keyDownHandler} role={!this.child ? "tree" : undefined} tabIndex={this.getRootTabIndex()} > @@ -244,72 +245,103 @@ export class Tree { event.stopPropagation(); } - @Listen("keydown") - keyDownHandler(event: KeyboardEvent): void { - const root = this.el.closest("calcite-tree:not([child])") as HTMLCalciteTreeElement; + private keyDownHandler = (event: KeyboardEvent): void => { + if (this.child) { + return; + } + + const root = this.el; const target = event.target as HTMLCalciteTreeItemElement; - if (!(root === this.el && target.tagName === "CALCITE-TREE-ITEM" && this.el.contains(target))) { + if ( + !(isTreeItem(target) && this.el.contains(target)) || + (event.key !== "ArrowRight" && + event.key !== "ArrowDown" && + event.key !== "ArrowLeft" && + event.key !== "ArrowUp" && + event.key !== "Home" && + event.key !== "End" && + event.key !== "Tab") + ) { return; } - if (event.key === "ArrowDown") { - const next = getEnabledSiblingItem(target.nextElementSibling, "down"); - if (next) { - next.focus(); - event.preventDefault(); - } + const traversableItems = getTraversableItems(root); + if (event.key === "Tab") { + // root tabindex will be restored when blurred/focused + traversableItems.forEach((item) => (item.tabIndex = -1)); + return; + } + + if (event.key === "ArrowDown") { + const currentItemIndex = traversableItems.indexOf(target); + const nextItem = traversableItems[currentItemIndex + 1]; + nextItem?.focus(); + event.preventDefault(); return; } if (event.key === "ArrowUp") { - const previous = getEnabledSiblingItem(target.previousElementSibling, "up"); - if (previous) { - previous.focus(); - event.preventDefault(); - } + const currentItemIndex = traversableItems.indexOf(target); + const previousItem = traversableItems[currentItemIndex - 1]; + previousItem?.focus(); + event.preventDefault(); + return; } - if (event.key === "ArrowLeft" && !target.disabled) { - // When focus is on an open node, closes the node. + if (event.key === "ArrowLeft") { if (target.hasChildren && target.expanded) { target.expanded = false; event.preventDefault(); return; } - // When focus is on a child node that is also either an end node or a closed node, moves focus to its parent node. - const parentItem = target.parentElement.closest("calcite-tree-item"); + const rootToItemPath = traversableItems.slice(0, traversableItems.indexOf(target)).reverse(); + const parentItem = rootToItemPath.find((item) => item.depth === target.depth - 1); - if (parentItem && (!target.hasChildren || target.expanded === false)) { - parentItem.focus(); - event.preventDefault(); - return; - } + parentItem?.focus(); + event.preventDefault(); - // When focus is on a root node that is also either an end node or a closed node, does nothing. return; } - if (event.key === "ArrowRight" && !target.disabled) { - if (target.hasChildren) { - if (target.expanded && getRootNode(this.el).activeElement === target) { - // When focus is on an open node, moves focus to the first child node. - getEnabledSiblingItem(target.querySelector("calcite-tree-item"), "down")?.focus(); + if (event.key === "ArrowRight") { + if (!target.disabled && target.hasChildren) { + if (!target.expanded) { + target.expanded = true; event.preventDefault(); } else { - // When focus is on a closed node, opens the node; focus does not move. - target.expanded = true; + const currentItemIndex = traversableItems.indexOf(target); + const nextItem = traversableItems[currentItemIndex + 1]; + nextItem?.focus(); event.preventDefault(); } } return; } - } - updateAncestorTree(event: CustomEvent): void { + if (event.key === "Home") { + const firstNode = traversableItems.shift(); + if (firstNode) { + firstNode.focus(); + event.preventDefault(); + } + return; + } + + if (event.key === "End") { + const lastNode = traversableItems.pop(); + if (lastNode) { + lastNode.focus(); + event.preventDefault(); + } + return; + } + }; + + private updateAncestorTree(event: CustomEvent): void { const item = event.target as HTMLCalciteTreeItemElement; const updateItem = event.detail.updateItem; diff --git a/packages/calcite-components/src/components/tree/utils.ts b/packages/calcite-components/src/components/tree/utils.ts index 81e392e8d86..f7537bd9480 100644 --- a/packages/calcite-components/src/components/tree/utils.ts +++ b/packages/calcite-components/src/components/tree/utils.ts @@ -1,20 +1,24 @@ -function isTreeItem(element: Element): element is HTMLCalciteTreeItemElement { - return element?.matches("calcite-tree-item"); +export function isTreeItem(element: Element): element is HTMLCalciteTreeItemElement { + return element?.tagName === "CALCITE-TREE-ITEM"; } -export function getEnabledSiblingItem(el: Element, direction: "up" | "down"): HTMLCalciteTreeItemElement { - const directionProp = direction === "down" ? "nextElementSibling" : "previousElementSibling"; - let currentEl: Element = el; - let enabledEl: HTMLCalciteTreeItemElement | null = null; +export function getTraversableItems(root: HTMLCalciteTreeElement): HTMLCalciteTreeItemElement[] { + return Array.from(root.querySelectorAll("calcite-tree-item:not([disabled])")).filter( + (item): boolean => { + let currentItem: HTMLElement = item; - while (isTreeItem(currentEl)) { - if (!currentEl.disabled) { - enabledEl = currentEl; - break; - } + while (currentItem !== root && currentItem !== undefined) { + const parent = currentItem.parentElement; + const traversable = !isTreeItem(parent) || !parent.hasChildren || parent.expanded; + + if (!traversable) { + return false; + } - currentEl = currentEl[directionProp]; - } + currentItem = currentItem.parentElement; + } - return enabledEl; + return true; + } + ); } From 2384dd64f67ceb933915d11f297f119b34cff999 Mon Sep 17 00:00:00 2001 From: JC Franco Date: Tue, 29 Aug 2023 14:32:26 -0700 Subject: [PATCH 2/2] tidy up --- .../calcite-components/src/components/tree/tree.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/packages/calcite-components/src/components/tree/tree.tsx b/packages/calcite-components/src/components/tree/tree.tsx index 507eb4ee96a..1d00c9920a4 100644 --- a/packages/calcite-components/src/components/tree/tree.tsx +++ b/packages/calcite-components/src/components/tree/tree.tsx @@ -253,16 +253,9 @@ export class Tree { const root = this.el; const target = event.target as HTMLCalciteTreeItemElement; - if ( - !(isTreeItem(target) && this.el.contains(target)) || - (event.key !== "ArrowRight" && - event.key !== "ArrowDown" && - event.key !== "ArrowLeft" && - event.key !== "ArrowUp" && - event.key !== "Home" && - event.key !== "End" && - event.key !== "Tab") - ) { + const supportedKeys = ["ArrowRight", "ArrowDown", "ArrowLeft", "ArrowUp", "Home", "End", "Tab"]; + + if (!(isTreeItem(target) && this.el.contains(target)) || !supportedKeys.includes(event.key)) { return; }