diff --git a/packages/components/src/components/dropdown-item/dropdown-item.browser.e2e.tsx b/packages/components/src/components/dropdown-item/dropdown-item.browser.e2e.tsx index 09067346d80..e7c06572ae3 100644 --- a/packages/components/src/components/dropdown-item/dropdown-item.browser.e2e.tsx +++ b/packages/components/src/components/dropdown-item/dropdown-item.browser.e2e.tsx @@ -1,6 +1,7 @@ -import { describe } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { mount } from "@arcgis/lumina-compiler/testing"; -import { disabled, focusable, hidden, renders } from "../../tests/commonTests/browser"; +import { focusable, hidden, renders } from "../../tests/commonTests/browser"; +import { afterNextTask } from "../../tests/utils/timing"; describe("is focusable", () => { focusable(() => mount(`calcite-dropdown-item`)); @@ -15,5 +16,37 @@ describe("renders", () => { }); describe("disabled", () => { - disabled(() => mount(`calcite-dropdown-item`)); + it("prevents selection event when disabled", async () => { + const { el, reRender } = await mount("calcite-dropdown-item"); + const selectSpy = vi.fn(); + el.addEventListener("calciteDropdownItemSelect", selectSpy); + + el.disabled = true; + await reRender(); + await afterNextTask(); + + el.click(); + + expect(selectSpy).toHaveBeenCalledTimes(0); + expect(el.getAttribute("aria-disabled")).toBe("true"); + }); + + it("allows selection event again after enabling", async () => { + const { el, reRender } = await mount("calcite-dropdown-item"); + const selectSpy = vi.fn(); + el.addEventListener("calciteDropdownItemSelect", selectSpy); + + el.disabled = true; + await reRender(); + await afterNextTask(); + + el.disabled = false; + await reRender(); + await afterNextTask(); + + el.click(); + + expect(selectSpy).toHaveBeenCalledTimes(1); + expect(el.getAttribute("aria-disabled")).toBeNull(); + }); }); diff --git a/packages/components/src/components/dropdown-item/dropdown-item.e2e.ts b/packages/components/src/components/dropdown-item/dropdown-item.e2e.ts index 8e9cc848677..4b509d3d112 100644 --- a/packages/components/src/components/dropdown-item/dropdown-item.e2e.ts +++ b/packages/components/src/components/dropdown-item/dropdown-item.e2e.ts @@ -16,22 +16,6 @@ it("should emit calciteDropdownItemSelect", async () => { await calciteDropdownItemSelectEventSpy.next(); expect(itemChangeSpy).toHaveReceivedEventTimes(1); - - await element.callMethod("setFocus"); - await page.waitForChanges(); - await page.keyboard.press("Enter"); - await page.waitForChanges(); - await calciteDropdownItemSelectEventSpy.next(); - - expect(itemChangeSpy).toHaveReceivedEventTimes(2); - - await element.callMethod("setFocus"); - await page.waitForChanges(); - await page.keyboard.press("Space"); - await page.waitForChanges(); - await calciteDropdownItemSelectEventSpy.next(); - - expect(itemChangeSpy).toHaveReceivedEventTimes(3); }); describe("theme", () => { diff --git a/packages/components/src/components/dropdown-item/dropdown-item.scss b/packages/components/src/components/dropdown-item/dropdown-item.scss index 69d3259e591..1dcd70cffad 100644 --- a/packages/components/src/components/dropdown-item/dropdown-item.scss +++ b/packages/components/src/components/dropdown-item/dropdown-item.scss @@ -111,7 +111,8 @@ } //focus -:host(:focus) { +:host(:focus), +:host([active-descendant]) { .container { @apply focus-inset no-underline; } diff --git a/packages/components/src/components/dropdown-item/dropdown-item.tsx b/packages/components/src/components/dropdown-item/dropdown-item.tsx index 1022384e7cf..72d260230cc 100644 --- a/packages/components/src/components/dropdown-item/dropdown-item.tsx +++ b/packages/components/src/components/dropdown-item/dropdown-item.tsx @@ -10,7 +10,6 @@ import { setAttribute, } from "@arcgis/lumina"; import { toAriaBoolean } from "../../utils/aria"; -import { ItemKeyboardEvent } from "../dropdown/interfaces"; import { RequestedItem } from "../dropdown-group/interfaces"; import { FlipContext, Scale, SelectionMode } from "../interfaces"; import { getIconScale } from "../../utils/component"; @@ -59,6 +58,13 @@ export class DropdownItem extends LitElement { /** When `true`, prevents interaction and decreases the component's opacity. */ @property({ reflect: true }) disabled = false; + /** + * When `true`, the component appears as if it is focused. + * + * @private + */ + @property({ reflect: true }) activeDescendant = false; + /** * Specifies the URL of the linked resource, which can be set as an absolute or relative path. * @@ -120,6 +126,24 @@ export class DropdownItem extends LitElement { return this.focusSetter(() => this.el, options); } + /** + * Activates the component as if it were clicked. + * + * @private + */ + @method() + async activateItem(): Promise { + if (this.disabled) { + return; + } + + this.emitRequestedItem(); + + if (this.href) { + this.childLinkRef.value?.click(); + } + } + //#endregion //#region Events @@ -127,12 +151,6 @@ export class DropdownItem extends LitElement { /** Fires when the component is selected. */ calciteDropdownItemSelect = createEvent({ cancelable: false }); - /** @private */ - calciteInternalDropdownCloseRequest = createEvent({ cancelable: false }); - - /** @private */ - calciteInternalDropdownItemKeyEvent = createEvent({ cancelable: false }); - /** @private */ calciteInternalDropdownItemSelect = createEvent({ cancelable: false }); @@ -143,7 +161,6 @@ export class DropdownItem extends LitElement { constructor() { super(); this.listen("click", this.onClick); - this.listen("keydown", this.keyDownHandler); this.listenOn( document.body, "calciteInternalDropdownItemChange", @@ -167,33 +184,6 @@ export class DropdownItem extends LitElement { this.emitRequestedItem(); } - private keyDownHandler(event: KeyboardEvent): void { - switch (event.key) { - case " ": - case "Enter": - this.emitRequestedItem(); - if (this.href) { - this.childLinkRef.value.click(); - } - event.preventDefault(); - break; - case "Escape": - this.calciteInternalDropdownCloseRequest.emit(); - event.preventDefault(); - break; - case "Tab": - this.calciteInternalDropdownItemKeyEvent.emit({ keyboardEvent: event }); - break; - case "ArrowUp": - case "ArrowDown": - case "Home": - case "End": - event.preventDefault(); - this.calciteInternalDropdownItemKeyEvent.emit({ keyboardEvent: event }); - break; - } - } - private updateActiveItemOnChange(event: CustomEvent): void { const parentEmittedChange = event.composedPath().includes(this.parentDropdownGroupEl); @@ -313,7 +303,7 @@ export class DropdownItem extends LitElement { /* TODO: [MIGRATION] This used before. In Stencil, props overwrite user-provided props. If you don't wish to overwrite user-values, replace "=" here with "??=" */ this.el.role = itemRole; /* TODO: [MIGRATION] This used before. In Stencil, props overwrite user-provided props. If you don't wish to overwrite user-values, add a check for this.el.hasAttribute() before calling setAttribute() here */ - setAttribute(this.el, "tabIndex", disabled ? -1 : 0); + setAttribute(this.el, "tabIndex", -1); return ( diff --git a/packages/components/src/components/dropdown/dropdown.browser.e2e.tsx b/packages/components/src/components/dropdown/dropdown.browser.e2e.tsx index 3e80ef631f2..8f4c22347f0 100644 --- a/packages/components/src/components/dropdown/dropdown.browser.e2e.tsx +++ b/packages/components/src/components/dropdown/dropdown.browser.e2e.tsx @@ -1,6 +1,7 @@ import { h } from "@arcgis/lumina"; import { mount } from "@arcgis/lumina-compiler/testing"; -import { describe } from "vitest"; +import { describe, expect, it } from "vitest"; +import { page, userEvent } from "vitest/browser"; import { JsxNode } from "@arcgis/lumina"; import { defaults, @@ -12,7 +13,9 @@ import { disabled, topLayer, } from "../../tests/commonTests/browser"; +import { afterNextTask } from "../../tests/utils/timing"; import { CSS } from "./resources"; +import { Dropdown } from "./dropdown"; describe("defaults", () => { defaults(() => mount("calcite-dropdown"), { @@ -125,7 +128,7 @@ describe("disabled", () => { focusTarget: { tab: "calcite-button", click: { - pointer: "calcite-dropdown-item", + pointer: "calcite-button", method: "body", }, }, @@ -136,3 +139,126 @@ describe("disabled", () => { describe("top layer placement", () => { topLayer(() => mount("calcite-dropdown")); }); + +describe("hover type", () => { + function createHoverDropdownHTML(): JsxNode { + return ( + + + Open dropdown + + + Dropdown Item Content + + Dropdown Item Content + + + + ); + } + + it("opens on focusin", async () => { + const { el } = await mount(createHoverDropdownHTML); + + expect(el.open).toBe(false); + + await userEvent.tab(); + await afterNextTask(); + await userEvent.tab(); + await afterNextTask(); + + expect(el.open).toBe(true); + }); + + it("does not toggle closed on click when type is hover", async () => { + const { el } = await mount(createHoverDropdownHTML); + const trigger = page.getByText("Open dropdown"); + + expect(el.open).toBe(false); + + await userEvent.click(trigger); + await afterNextTask(); + + expect(el.open).toBe(true); + + await userEvent.click(trigger); + await afterNextTask(); + + expect(el.open).toBe(true); + }); + + it("closes when focus leaves trigger with Tab", async () => { + const { el } = await mount( +
+ {createHoverDropdownHTML()} + +
, + ); + const dropdownEl = el as Dropdown["el"]; + const trigger = page.getByText("Open dropdown"); + const nextFocusTarget = page.getByRole("button", { name: "Next" }); + + await userEvent.click(trigger); + await afterNextTask(); + expect(dropdownEl.open).toBe(true); + + await userEvent.tab(); + await afterNextTask(); + + await expect.element(nextFocusTarget).toHaveFocus(); + expect(dropdownEl.open).toBe(false); + }); +}); + +describe("ariaActiveDescendantElement", () => { + it("sets ariaActiveDescendantElement on the trigger slot when opened", async () => { + const { el } = await mount(createSimpleDropdownHTML); + const trigger = page.getByText("Open dropdown"); + + await userEvent.click(trigger); + await afterNextTask(); + + const triggerSlot = el.shadowRoot.querySelector("slot[name='trigger']") as HTMLSlotElement; + + expect(triggerSlot.ariaActiveDescendantElement?.id).toBe("item-2"); + }); + + it("updates ariaActiveDescendantElement on keyboard navigation", async () => { + const { el } = await mount(createSimpleDropdownHTML); + const trigger = page.getByText("Open dropdown"); + + await userEvent.click(trigger); + await afterNextTask(); + + const referenceEl = el.shadowRoot.querySelector(`.${CSS.triggerContainer}`) as HTMLDivElement; + const triggerSlot = el.shadowRoot.querySelector("slot[name='trigger']") as HTMLSlotElement; + + referenceEl.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + await afterNextTask(); + + expect(triggerSlot.ariaActiveDescendantElement?.id).toBe("item-3"); + }); + + it("wraps ariaActiveDescendantElement on ArrowUp navigation", async () => { + const { el } = await mount(createSimpleDropdownHTML); + const trigger = page.getByText("Open dropdown"); + + await userEvent.click(trigger); + await afterNextTask(); + + const referenceEl = el.shadowRoot.querySelector(`.${CSS.triggerContainer}`) as HTMLDivElement; + const triggerSlot = el.shadowRoot.querySelector("slot[name='trigger']") as HTMLSlotElement; + + referenceEl.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); + await afterNextTask(); + + expect(triggerSlot.ariaActiveDescendantElement?.id).toBe("item-1"); + + referenceEl.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowUp", bubbles: true })); + await afterNextTask(); + + expect(triggerSlot.ariaActiveDescendantElement?.id).toBe("item-3"); + }); +}); diff --git a/packages/components/src/components/dropdown/dropdown.e2e.ts b/packages/components/src/components/dropdown/dropdown.e2e.ts index 857fcbc0d5d..ed9db124adb 100644 --- a/packages/components/src/components/dropdown/dropdown.e2e.ts +++ b/packages/components/src/components/dropdown/dropdown.e2e.ts @@ -7,7 +7,6 @@ import { createSelectedItemsAsserter, findAll, getFocusedElementProp, - isElementFocused, skipAnimations, } from "../../tests/utils/puppeteer"; import type { DropdownItem } from "../dropdown-item/dropdown-item"; @@ -431,7 +430,13 @@ describe("calcite-dropdown", () => { const openEventSpy = await page.spyOnEvent("calciteDropdownOpen"); await element.click(); await openEventSpy.next(); - expect(await page.evaluate(() => document.activeElement.id)).toEqual("item-1"); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toEqual("item-1"); }); it("should focus the first selected item on open", async () => { @@ -452,7 +457,13 @@ describe("calcite-dropdown", () => { await element.click(); await dropdownOpenEventSpy.next(); - expect(await page.evaluate(() => document.activeElement.id)).toEqual("item-3"); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toEqual("item-3"); }); it("should focus the first selected item on open (multi)", async () => { @@ -473,7 +484,13 @@ describe("calcite-dropdown", () => { await element.click(); await dropdownOpenEventSpy.next(); - expect(await page.evaluate(() => document.activeElement.id)).toEqual("item-2"); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toEqual("item-2"); }); describe("scrolling", () => { @@ -534,7 +551,13 @@ describe("calcite-dropdown", () => { await element.click(); await dropdownOpenEventSpy.next(); - expect(await page.evaluate(() => document.activeElement.id)).toEqual("item-50"); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toEqual("item-50"); const item = await page.find("#item-50"); @@ -683,7 +706,7 @@ describe("calcite-dropdown", () => { expect(await dropdownWrapper.isVisible()).toBe(false); }); - it("does not close when close-on-select is disabled and a selection is made", async () => { + it("does not close when close-on-select is disabled and a selection is made, except selection-mode none", async () => { const page = await newE2EPage(); await page.setContent( html` @@ -722,7 +745,7 @@ describe("calcite-dropdown", () => { await noneGroupItem.click(); await page.waitForChanges(); - expect(await dropdownWrapper.isVisible()).toBe(true); + expect(await dropdownWrapper.isVisible()).toBe(false); }); describe("toggles the dropdown with click, enter, or space", () => { @@ -887,7 +910,13 @@ describe("calcite-dropdown", () => { expect(await dropdownWrapper.isVisible()).toBe(true); expect(calciteDropdownOpen).toHaveReceivedEventTimes(1); expect(calciteDropdownClose).toHaveReceivedEventTimes(0); - expect(await getFocusedElementProp(page, "id")).toBe("item-2"); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-2"); const closeEventSpy = await page.spyOnEvent("calciteDropdownClose"); await element.press("Tab"); @@ -899,9 +928,10 @@ describe("calcite-dropdown", () => { expect(await dropdownWrapper.isVisible()).toBe(false); }); - it("closes dropdown and focuses the trigger on Shift+Tab", async () => { + it("closes dropdown and focuses the previous focusable element on Shift+Tab", async () => { const page = await newE2EPage(); await page.setContent(html` + Before Open dropdown @@ -927,7 +957,13 @@ describe("calcite-dropdown", () => { expect(await dropdownWrapper.isVisible()).toBe(true); expect(calciteDropdownOpen).toHaveReceivedEventTimes(1); expect(calciteDropdownClose).toHaveReceivedEventTimes(0); - expect(await getFocusedElementProp(page, "id")).toBe("item-2"); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-2"); const closeEventSpy = await page.spyOnEvent("calciteDropdownClose"); await page.keyboard.down("Shift"); @@ -936,7 +972,7 @@ describe("calcite-dropdown", () => { await page.waitForChanges(); await closeEventSpy.next(); - expect(await getFocusedElementProp(page, "id")).toBe("trigger"); + expect(await getFocusedElementProp(page, "id")).toBe("button-0"); expect(calciteDropdownClose).toHaveReceivedEventTimes(1); expect(await dropdownWrapper.isVisible()).toBe(false); }); @@ -1201,42 +1237,90 @@ describe("calcite-dropdown", () => { await page.waitForChanges(); await openEventSpy.next(); - expect(await isElementFocused(page, "#item-1")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-1"); await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-2")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-2"); await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-3")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-3"); await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-1")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-1"); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-3")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-3"); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-2")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-2"); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-1")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-1"); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-3")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-3"); }); it("skips disabled and hidden items when navigating with arrow keys", async () => { @@ -1265,32 +1349,68 @@ describe("calcite-dropdown", () => { await page.waitForChanges(); await openEventSpy.next(); - expect(await isElementFocused(page, "#item-2")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-2"); await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-3")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-3"); await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-2")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-2"); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-3")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-3"); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-2")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-2"); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-3")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-3"); }); it("should open the dropdown and focus the first item with ArrowDown", async () => { @@ -1317,15 +1437,33 @@ describe("calcite-dropdown", () => { await openEventSpy.next(); expect(await dropdown.getProperty("open")).toBe(true); - expect(await isElementFocused(page, "#item-1")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-1"); await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-2")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-2"); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-1")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-1"); }); it("should open the dropdown and focus the last item with ArrowUp", async () => { @@ -1352,15 +1490,33 @@ describe("calcite-dropdown", () => { await openEventSpy.next(); expect(await dropdown.getProperty("open")).toBe(true); - expect(await isElementFocused(page, "#item-3")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-3"); await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-1")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-1"); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-3")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-3"); }); it("should open the dropdown and focus the selected item with ArrowDown", async () => { @@ -1387,15 +1543,33 @@ describe("calcite-dropdown", () => { await openEventSpy.next(); expect(await dropdown.getProperty("open")).toBe(true); - expect(await isElementFocused(page, "#item-2")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-2"); await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-3")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-3"); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-2")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-2"); }); it("should open the dropdown and focus the selected item with ArrowUp", async () => { @@ -1422,15 +1596,33 @@ describe("calcite-dropdown", () => { await openEventSpy.next(); expect(await dropdown.getProperty("open")).toBe(true); - expect(await isElementFocused(page, "#item-2")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-2"); await page.keyboard.press("ArrowUp"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-1")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-1"); await page.keyboard.press("ArrowDown"); await page.waitForChanges(); - expect(await isElementFocused(page, "#item-2")).toBe(true); + expect( + await page.evaluate( + () => + document.querySelector("calcite-dropdown").shadowRoot.querySelector("slot[name='trigger']") + .ariaActiveDescendantElement?.id, + ), + ).toBe("item-2"); }); }); diff --git a/packages/components/src/components/dropdown/dropdown.tsx b/packages/components/src/components/dropdown/dropdown.tsx index 6f93775a239..3836dcff037 100644 --- a/packages/components/src/components/dropdown/dropdown.tsx +++ b/packages/components/src/components/dropdown/dropdown.tsx @@ -1,9 +1,9 @@ // @ts-strict-ignore import { PropertyValues } from "lit"; -import { createEvent, h, JsxNode, LitElement, method, property } from "@arcgis/lumina"; -import { queryAssignedElements } from "lit/decorators.js"; +import { createEvent, h, JsxNode, LitElement, method, property, state } from "@arcgis/lumina"; import { useDirection } from "@arcgis/lumina/controllers"; -import { focusElement, focusElementInGroup, nextFrame } from "../../utils/dom"; +import { nil } from "@arcgis/toolkit/type"; +import { nextFrame } from "../../utils/dom"; import { connectFloatingUI, defaultMenuPlacement, @@ -17,20 +17,18 @@ import { OverlayPositioning, reposition, } from "../../utils/floating-ui"; -import { guid } from "../../utils/guid"; import { isActivationKey } from "../../utils/key"; import { createObserver, updateRefObserver } from "../../utils/observers"; import { toggleOpenClose } from "../../utils/openCloseComponent"; import { getDimensionClass } from "../../utils/dynamicClasses"; import { RequestedItem } from "../dropdown-group/interfaces"; -import { Scale, SingleItemSlotArray, Width } from "../interfaces"; +import { Scale, Width } from "../interfaces"; import type { DropdownItem } from "../dropdown-item/dropdown-item"; import type { DropdownGroup } from "../dropdown-group/dropdown-group"; import { useSetFocus } from "../../controllers/useSetFocus"; import { useInteractive } from "../../controllers/useInteractive"; import { useTopLayer } from "../../controllers/useTopLayer"; -import { ItemKeyboardEvent } from "./interfaces"; -import { CSS, IDS, SLOTS } from "./resources"; +import { CSS, SLOTS } from "./resources"; import { styles } from "./dropdown.scss"; declare global { @@ -62,9 +60,9 @@ export class Dropdown extends LitElement implements FloatingUIComponent { private focusLastDropdownItem = false; - private groups: DropdownGroup["el"][] = []; + private activeItemIndex = -1; - private guid = guid(); + private groups: DropdownGroup["el"][] = []; private items: DropdownItem["el"][] = []; @@ -82,9 +80,6 @@ export class Dropdown extends LitElement implements FloatingUIComponent { transitionEl: HTMLDivElement; - @queryAssignedElements({ slot: SLOTS.trigger }) - private triggerEls: SingleItemSlotArray; - private focusSetter = useSetFocus()(this); private interactiveContainer = useInteractive(this); @@ -95,6 +90,12 @@ export class Dropdown extends LitElement implements FloatingUIComponent { //#endregion + //#region State Properties + + @state() activeDescendantElement?: DropdownItem["el"]; + + //#endregion + //#region Public Properties /** @@ -249,11 +250,9 @@ export class Dropdown extends LitElement implements FloatingUIComponent { constructor() { super(); this.listenOn(window, "click", this.closeCalciteDropdownOnClick); - this.listen("calciteInternalDropdownCloseRequest", this.closeCalciteDropdownOnEvent); this.listenOn(window, "calciteDropdownOpen", this.closeCalciteDropdownOnOpenEvent); this.listen("pointerenter", this.pointerEnterHandler); this.listen("pointerleave", this.pointerLeaveHandler); - this.listen("calciteInternalDropdownItemKeyEvent", this.calciteInternalDropdownItemKeyEvent); this.listen("calciteInternalDropdownItemSelect", this.handleItemSelect); } @@ -345,12 +344,7 @@ export class Dropdown extends LitElement implements FloatingUIComponent { return; } - this.closeCalciteDropdown(false); - } - - private closeCalciteDropdownOnEvent(event: Event): void { this.closeCalciteDropdown(); - event.stopPropagation(); } private closeCalciteDropdownOnOpenEvent(event: Event): void { @@ -358,7 +352,7 @@ export class Dropdown extends LitElement implements FloatingUIComponent { return; } - this.open = false; + this.closeCalciteDropdown(); } private pointerEnterHandler(): void { @@ -366,7 +360,7 @@ export class Dropdown extends LitElement implements FloatingUIComponent { return; } - this.toggleDropdown(); + this.open = true; } private pointerLeaveHandler(): void { @@ -381,38 +375,15 @@ export class Dropdown extends LitElement implements FloatingUIComponent { return this.items.filter((item) => !item.disabled && !item.hidden); } - private calciteInternalDropdownItemKeyEvent(event: CustomEvent): void { - const { keyboardEvent } = event.detail; - const target = keyboardEvent.target as DropdownItem["el"]; - const traversableItems = this.getTraversableItems(); - - switch (keyboardEvent.key) { - case "Tab": - this.open = false; - this.updateTabIndexOfItems(target); - break; - case "ArrowDown": - focusElementInGroup(traversableItems, target, "next"); - break; - case "ArrowUp": - focusElementInGroup(traversableItems, target, "previous"); - break; - case "Home": - focusElementInGroup(traversableItems, target, "first"); - break; - case "End": - focusElementInGroup(traversableItems, target, "last"); - break; - } - - event.stopPropagation(); - } - - private handleItemSelect(event: CustomEvent): void { + private async handleItemSelect(event: CustomEvent): Promise { this.updateSelectedItems(); + this.syncActiveItemFromTraversableItems(); event.stopPropagation(); this.calciteDropdownSelect.emit(); - if (!this.closeOnSelectDisabled) { + await this.setFocus(); + const requestedDropdownGroup = event.detail.requestedDropdownGroup; + const selectionModeIsNone = requestedDropdownGroup?.selectionMode === "none"; + if (!this.closeOnSelectDisabled || selectionModeIsNone) { this.closeCalciteDropdown(); } } @@ -431,6 +402,7 @@ export class Dropdown extends LitElement implements FloatingUIComponent { .reduce((previousValue, currentValue) => [...previousValue, ...currentValue], []); this.updateSelectedItems(); + this.syncActiveItemFromTraversableItems(); this.reposition(true); @@ -497,7 +469,7 @@ export class Dropdown extends LitElement implements FloatingUIComponent { } onBeforeOpen(): void { - this.focusOnFirstActiveOrDefaultItem(); + this.setInitialActiveItem(); this.calciteDropdownBeforeOpen.emit(); this.topLayer.show(); } @@ -538,25 +510,61 @@ export class Dropdown extends LitElement implements FloatingUIComponent { return; } - if (key === "Escape") { + if (this.open && key === "Escape") { this.closeCalciteDropdown(); event.preventDefault(); return; } - if (this.open && event.shiftKey && key === "Tab") { - this.closeCalciteDropdown(); + if (!this.open && isActivationKey(key)) { + this.open = true; event.preventDefault(); return; } - if (isActivationKey(key)) { - this.toggleDropdown(); - event.preventDefault(); - } else if (key === "ArrowDown" || key === "ArrowUp") { + if (!this.open && (key === "ArrowDown" || key === "ArrowUp")) { event.preventDefault(); this.focusLastDropdownItem = key === "ArrowUp"; this.open = true; + return; + } + + if (!this.open) { + return; + } + + if (key === "Tab") { + this.closeCalciteDropdown(); + return; + } + + if (key === "ArrowDown") { + event.preventDefault(); + this.navigateActiveItem("next"); + return; + } + + if (key === "ArrowUp") { + event.preventDefault(); + this.navigateActiveItem("previous"); + return; + } + + if (key === "Home") { + event.preventDefault(); + this.navigateActiveItem("first"); + return; + } + + if (key === "End") { + event.preventDefault(); + this.navigateActiveItem("last"); + return; + } + + if (isActivationKey(key)) { + event.preventDefault(); + this.activateActiveItem(); } } @@ -569,21 +577,90 @@ export class Dropdown extends LitElement implements FloatingUIComponent { return last.offsetTop + style.height; } - private closeCalciteDropdown(focusTrigger = true) { + private closeCalciteDropdown(): void { this.open = false; - - if (focusTrigger) { - focusElement(this.triggerEls[0]); - } + this.setActiveItemByIndex(-1); } - private async focusOnFirstActiveOrDefaultItem(): Promise { + private async setInitialActiveItem(): Promise { const selectedItem = this.getTraversableItems().find((item) => item.selected); + const traversableItems = this.getTraversableItems(); const target: DropdownItem["el"] = - selectedItem || (this.focusLastDropdownItem ? this.items.at(-1) : this.items[0]); + selectedItem || (this.focusLastDropdownItem ? traversableItems.at(-1) : traversableItems[0]); this.focusLastDropdownItem = false; + if (!target) { + this.setActiveItemByIndex(-1); + return; + } + + const targetIndex = traversableItems.findIndex((item) => item === target); + this.setActiveItemByIndex(targetIndex); + + await this.scrollActiveItemIntoView(target); + } + + private syncActiveItemFromTraversableItems(): void { + const traversableItems = this.getTraversableItems(); + + if (!traversableItems.length) { + this.setActiveItemByIndex(-1); + return; + } + + if (this.activeItemIndex < 0 || this.activeItemIndex >= traversableItems.length) { + this.setActiveItemByIndex(0); + return; + } + + this.updateActiveDescendantElement(traversableItems[this.activeItemIndex]); + } + + private setActiveItemByIndex(index: number): void { + this.activeItemIndex = index; + const traversableItems = this.getTraversableItems(); + const activeItem = index >= 0 ? traversableItems[index] : null; + + this.updateActiveDescendantElement(activeItem); + } + + private updateActiveDescendantElement(activeItem: DropdownItem["el"] | nil): void { + this.items.forEach((item) => { + item.activeDescendant = item === activeItem; + }); + + this.activeDescendantElement = activeItem ?? null; + } + + private navigateActiveItem(direction: "next" | "previous" | "first" | "last"): void { + const traversableItems = this.getTraversableItems(); + + if (!traversableItems.length) { + return; + } + + const totalItems = traversableItems.length; + let index = this.activeItemIndex; + + if (index < 0 || index >= totalItems) { + index = direction === "previous" || direction === "last" ? totalItems - 1 : 0; + } else if (direction === "next") { + index = (index + 1) % totalItems; + } else if (direction === "previous") { + index = (index - 1 + totalItems) % totalItems; + } else if (direction === "first") { + index = 0; + } else if (direction === "last") { + index = totalItems - 1; + } + + const activeItem = traversableItems[index]; + this.setActiveItemByIndex(index); + void this.scrollActiveItemIntoView(activeItem); + } + + private async scrollActiveItemIntoView(target: DropdownItem["el"]): Promise { if (!target) { return; } @@ -594,18 +671,51 @@ export class Dropdown extends LitElement implements FloatingUIComponent { await nextFrame(); await nextFrame(); - await focusElement(target); target.scrollIntoView({ block: "nearest" }); } - private toggleDropdown() { - this.open = !this.open; + private activateActiveItem(): void { + const traversableItems = this.getTraversableItems(); + const activeItem = traversableItems[this.activeItemIndex] || traversableItems[0]; + + if (!activeItem) { + return; + } + + this.setActiveItemByIndex(traversableItems.findIndex((item) => item === activeItem)); + activeItem.activateItem(); } - private updateTabIndexOfItems(target: DropdownItem["el"]): void { - this.items.forEach((item: DropdownItem["el"]) => { - item.tabIndex = target !== item ? -1 : 0; - }); + private openHoverDropdown(): void { + if (this.open || this.disabled || this.type !== "hover") { + return; + } + + this.open = true; + } + + private closeHoverDropdown(event: FocusEvent): void { + if (!this.open || this.disabled || this.type !== "hover") { + return; + } + const relatedTarget = event.relatedTarget as Node | null; + if ( + relatedTarget && + (this.el.contains(relatedTarget) || + (this.referenceEl != null && this.referenceEl.contains(relatedTarget))) + ) { + return; + } + + this.closeCalciteDropdown(); + } + + private toggleClickDropdown(): void { + if (this.disabled || this.type !== "click") { + return; + } + + this.open = !this.open; } //#endregion @@ -613,42 +723,43 @@ export class Dropdown extends LitElement implements FloatingUIComponent { //#region Rendering override render(): JsxNode { - const { open, guid } = this; + const { open } = this; return (