diff --git a/packages/calcite-components/src/components/dropdown-item/dropdown-item.e2e.ts b/packages/calcite-components/src/components/dropdown-item/dropdown-item.e2e.ts index 3de2902a761..3c043d9b2b8 100644 --- a/packages/calcite-components/src/components/dropdown-item/dropdown-item.e2e.ts +++ b/packages/calcite-components/src/components/dropdown-item/dropdown-item.e2e.ts @@ -1,5 +1,5 @@ import { newE2EPage } from "@stencil/core/testing"; -import { focusable, renders, hidden } from "../../tests/commonTests"; +import { focusable, renders, hidden, disabled } from "../../tests/commonTests"; describe("calcite-dropdown-item", () => { describe("renders", () => { @@ -14,6 +14,10 @@ describe("calcite-dropdown-item", () => { focusable(`calcite-dropdown-item`); }); + describe("disabled", () => { + disabled(`calcite-dropdown-item`); + }); + it("should emit calciteDropdownItemSelect", async () => { const page = await newE2EPage(); await page.setContent(` Dropdown Item Content `); diff --git a/packages/calcite-components/src/components/dropdown-item/dropdown-item.scss b/packages/calcite-components/src/components/dropdown-item/dropdown-item.scss index 3aad51218ef..e409cc47d7f 100644 --- a/packages/calcite-components/src/components/dropdown-item/dropdown-item.scss +++ b/packages/calcite-components/src/components/dropdown-item/dropdown-item.scss @@ -219,3 +219,4 @@ } @include base-component(); +@include disabled(); diff --git a/packages/calcite-components/src/components/dropdown-item/dropdown-item.tsx b/packages/calcite-components/src/components/dropdown-item/dropdown-item.tsx index eb86d08cc4d..a28f837fa4d 100644 --- a/packages/calcite-components/src/components/dropdown-item/dropdown-item.tsx +++ b/packages/calcite-components/src/components/dropdown-item/dropdown-item.tsx @@ -22,6 +22,7 @@ import { setUpLoadableComponent, } from "../../utils/loadable"; import { getIconScale } from "../../utils/component"; +import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; /** * @slot - A slot for adding text. @@ -31,15 +32,24 @@ import { getIconScale } from "../../utils/component"; styleUrl: "dropdown-item.scss", shadow: true, }) -export class DropdownItem implements LoadableComponent { +export class DropdownItem implements InteractiveComponent, LoadableComponent { //-------------------------------------------------------------------------- // // Public Properties // //-------------------------------------------------------------------------- - /** When `true`, the component is selected. */ - @Prop({ reflect: true, mutable: true }) selected = false; + /** + * When `true`, interaction is prevented and the component is displayed with lower opacity. + */ + @Prop({ reflect: true }) disabled = false; + + /** + * Specifies the URL of the linked resource, which can be set as an absolute or relative path. + * + * Determines if the component will render as an anchor. + */ + @Prop({ reflect: true }) href: string; /** Displays the `iconStart` and/or `iconEnd` as flipped when the element direction is right-to-left (`"rtl"`). */ @Prop({ reflect: true }) iconFlipRtl: FlipContext; @@ -50,19 +60,15 @@ export class DropdownItem implements LoadableComponent { /** Specifies an icon to display at the end of the component. */ @Prop({ reflect: true }) iconEnd: string; - /** - * Specifies the URL of the linked resource, which can be set as an absolute or relative path. - * - * Determines if the component will render as an anchor. - */ - @Prop({ reflect: true }) href: string; - /** Accessible name for the component. */ @Prop() label: string; /** Specifies the relationship to the linked document defined in `href`. */ @Prop({ reflect: true }) rel: string; + /** When `true`, the component is selected. */ + @Prop({ reflect: true, mutable: true }) selected = false; + /** Specifies the frame or window to open the linked document. */ @Prop({ reflect: true }) target: string; @@ -136,6 +142,10 @@ export class DropdownItem implements LoadableComponent { this.initialize(); } + componentDidRender(): void { + updateHostInteraction(this, "managed"); + } + render(): VNode { const { href, selectionMode, label, iconFlipRtl, scale } = this; diff --git a/packages/calcite-components/src/components/dropdown/dropdown.e2e.ts b/packages/calcite-components/src/components/dropdown/dropdown.e2e.ts index 70df138aa16..b11be0ad4c1 100644 --- a/packages/calcite-components/src/components/dropdown/dropdown.e2e.ts +++ b/packages/calcite-components/src/components/dropdown/dropdown.e2e.ts @@ -12,7 +12,7 @@ import { reflects, renders, } from "../../tests/commonTests"; -import { GlobalTestProps, getFocusedElementProp } from "../../tests/utils"; +import { GlobalTestProps, getFocusedElementProp, isElementFocused, skipAnimations } from "../../tests/utils"; describe("calcite-dropdown", () => { const simpleDropdownHTML = html` @@ -1232,5 +1232,118 @@ describe("calcite-dropdown", () => { } ); }); + + describe("keyboard navigation", () => { + it("supports navigating through items with arrow keys", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + Open + + 1 + 2 + 3 + + + `); + await skipAnimations(page); + + const dropdown = await page.find("calcite-dropdown"); + await dropdown.callMethod("setFocus"); + await page.waitForChanges(); + + await page.keyboard.press("Enter"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-1")).toBe(true); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-2")).toBe(true); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-3")).toBe(true); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-1")).toBe(true); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-3")).toBe(true); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-2")).toBe(true); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-1")).toBe(true); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-3")).toBe(true); + }); + + it("skips disabled and hidden items when navigating with arrow keys", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + Open + + 1 + 1.5 + 2 + + 3 + + + + `); + await skipAnimations(page); + + const dropdown = await page.find("calcite-dropdown"); + await dropdown.callMethod("setFocus"); + await page.waitForChanges(); + + await page.keyboard.press("Enter"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-2")).toBe(true); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-3")).toBe(true); + + await page.keyboard.press("ArrowDown"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-2")).toBe(true); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-3")).toBe(true); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-2")).toBe(true); + + await page.keyboard.press("ArrowUp"); + await page.waitForChanges(); + + expect(await isElementFocused(page, "#item-3")).toBe(true); + }); + }); }); }); diff --git a/packages/calcite-components/src/components/dropdown/dropdown.stories.ts b/packages/calcite-components/src/components/dropdown/dropdown.stories.ts index b3f18ff15b1..60f6a5f5b46 100644 --- a/packages/calcite-components/src/components/dropdown/dropdown.stories.ts +++ b/packages/calcite-components/src/components/dropdown/dropdown.stories.ts @@ -323,23 +323,43 @@ export const noScrollingWhenMaxItemsEqualsItems_TestOnly = (): string => html` < `; -export const disabled_TestOnly = (): string => html` - Open Dropdown - - 1 - 2 - 3 - 4 - 5 - - - 6 - 7 - 8 - 9 - 10 - -`; +export const disabled_TestOnly = (): string => html` + + Disabled dropdown + + 1 + 2 + 3 + 4 + 5 + + + 6 + 7 + 8 + 9 + 10 + + + + + Disabled dropdown items + + 1 + 2 + 3 + 4 + 5 + + + 6 + 7 + 8 + 9 + 10 + + +`; export const flipPositioning_TestOnly = (): string => html`
diff --git a/packages/calcite-components/src/components/dropdown/dropdown.tsx b/packages/calcite-components/src/components/dropdown/dropdown.tsx index ad0e3c18d87..fd309fb2a08 100644 --- a/packages/calcite-components/src/components/dropdown/dropdown.tsx +++ b/packages/calcite-components/src/components/dropdown/dropdown.tsx @@ -374,10 +374,15 @@ export class Dropdown this.closeCalciteDropdown(); } + private getTraversableItems(): HTMLCalciteDropdownItemElement[] { + return this.items.filter((item) => !item.disabled && !item.hidden); + } + @Listen("calciteInternalDropdownItemKeyEvent") calciteInternalDropdownItemKeyEvent(event: CustomEvent): void { const { keyboardEvent } = event.detail; const target = keyboardEvent.target as HTMLCalciteDropdownItemElement; + const traversableItems = this.getTraversableItems(); switch (keyboardEvent.key) { case "Tab": @@ -385,16 +390,16 @@ export class Dropdown this.updateTabIndexOfItems(target); break; case "ArrowDown": - focusElementInGroup(this.items, target, "next"); + focusElementInGroup(traversableItems, target, "next"); break; case "ArrowUp": - focusElementInGroup(this.items, target, "previous"); + focusElementInGroup(traversableItems, target, "previous"); break; case "Home": - focusElementInGroup(this.items, target, "first"); + focusElementInGroup(traversableItems, target, "first"); break; case "End": - focusElementInGroup(this.items, target, "last"); + focusElementInGroup(traversableItems, target, "last"); break; } @@ -645,7 +650,9 @@ export class Dropdown } private focusOnFirstActiveOrFirstItem = (): void => { - this.getFocusableElement(this.items.find((item) => item.selected) || this.items[0]); + this.getFocusableElement( + this.getTraversableItems().find((item) => item.selected) || this.items[0] + ); }; private getFocusableElement(item): void {