From ceaf5320bd0f9fdeff4f4a7180e4a718c9743031 Mon Sep 17 00:00:00 2001 From: Ben Elan Date: Thu, 2 Feb 2023 23:39:02 -0600 Subject: [PATCH] feat(pagination, split-button, dropdown, date-picker) action-group): add setFocus method (#6405) **Related Issue:** #5147 ## Summary Adds the public `setFocus` method to the `delegatesFocus` components that didn't have it. See https://github.com/Esri/calcite-components/issues/5147#issuecomment-1355194965 --- .../action-group/action-group.e2e.ts | 25 ++++------ src/components/action-group/action-group.tsx | 30 ++++++++++-- src/components/date-picker/date-picker.e2e.ts | 11 +++-- src/components/date-picker/date-picker.tsx | 27 ++++++++++- src/components/dropdown/dropdown.e2e.ts | 46 ++++++++----------- src/components/dropdown/dropdown.tsx | 28 ++++++++++- src/components/pagination/pagination.e2e.ts | 16 +++++-- src/components/pagination/pagination.tsx | 22 ++++++++- .../split-button/split-button.e2e.ts | 12 ++++- src/components/split-button/split-button.tsx | 41 ++++++++++++++++- src/tests/commonTests.ts | 13 +++--- 11 files changed, 207 insertions(+), 64 deletions(-) diff --git a/src/components/action-group/action-group.e2e.ts b/src/components/action-group/action-group.e2e.ts index 6cc45bfea59..84176f49f19 100755 --- a/src/components/action-group/action-group.e2e.ts +++ b/src/components/action-group/action-group.e2e.ts @@ -1,31 +1,26 @@ -import { accessible, hidden, renders, slots, t9n } from "../../tests/commonTests"; import { newE2EPage } from "@stencil/core/testing"; +import { accessible, focusable, hidden, renders, slots, t9n } from "../../tests/commonTests"; import { SLOTS } from "./resources"; +const actionGroupHTML = ` + + + `; + describe("calcite-action-group", () => { it("renders", async () => renders("calcite-action-group", { display: "flex" })); + it("focusable", async () => focusable(actionGroupHTML, { shadowFocusTargetSelector: "calcite-action" })); + it("honors hidden attribute", async () => hidden("calcite-action-group")); - it("should be accessible", async () => - accessible(` - - - - `)); + it("should be accessible", async () => accessible(actionGroupHTML)); it("has slots", () => slots("calcite-action-group", SLOTS)); it("should honor scale of expand icon", async () => { - const page = await newE2EPage({ - html: ` - - - ` - }); - + const page = await newE2EPage({ html: actionGroupHTML }); const menu = await page.find(`calcite-action-group >>> calcite-action-menu`); - expect(await menu.getProperty("scale")).toBe("l"); }); diff --git a/src/components/action-group/action-group.tsx b/src/components/action-group/action-group.tsx index da3972a3545..c97b98d5382 100755 --- a/src/components/action-group/action-group.tsx +++ b/src/components/action-group/action-group.tsx @@ -1,5 +1,4 @@ -import { Component, Element, h, Prop, Watch } from "@stencil/core"; -import { Fragment, State, VNode } from "@stencil/core/internal"; +import { Component, Element, Fragment, h, Method, Prop, State, VNode, Watch } from "@stencil/core"; import { CalciteActionMenuCustomEvent } from "../../components"; import { ConditionalSlotComponent, @@ -7,6 +6,12 @@ import { disconnectConditionalSlotComponent } from "../../utils/conditionalSlot"; import { getSlotted } from "../../utils/dom"; +import { + componentLoaded, + LoadableComponent, + setComponentLoaded, + setUpLoadableComponent +} from "../../utils/loadable"; import { connectLocalized, disconnectLocalized, LocalizedComponent } from "../../utils/locale"; import { connectMessages, @@ -33,7 +38,9 @@ import { ICONS, SLOTS } from "./resources"; }, assetsDirs: ["assets"] }) -export class ActionGroup implements ConditionalSlotComponent, LocalizedComponent, T9nComponent { +export class ActionGroup + implements ConditionalSlotComponent, LoadableComponent, LocalizedComponent, T9nComponent +{ // -------------------------------------------------------------------------- // // Properties @@ -103,6 +110,18 @@ export class ActionGroup implements ConditionalSlotComponent, LocalizedComponent @State() defaultMessages: ActionGroupMessages; + //-------------------------------------------------------------------------- + // + // Public Methods + // + //-------------------------------------------------------------------------- + + /** Sets focus on the component's first focusable element. */ + @Method() + async setFocus(): Promise { + await componentLoaded(this); + this.el.focus(); + } // -------------------------------------------------------------------------- // // Lifecycle @@ -122,9 +141,14 @@ export class ActionGroup implements ConditionalSlotComponent, LocalizedComponent } async componentWillLoad(): Promise { + setUpLoadableComponent(this); await setUpMessages(this); } + componentDidLoad(): void { + setComponentLoaded(this); + } + // -------------------------------------------------------------------------- // // Component Methods diff --git a/src/components/date-picker/date-picker.e2e.ts b/src/components/date-picker/date-picker.e2e.ts index 10473c3044d..7414b3062ac 100644 --- a/src/components/date-picker/date-picker.e2e.ts +++ b/src/components/date-picker/date-picker.e2e.ts @@ -1,8 +1,7 @@ import { E2EPage, newE2EPage } from "@stencil/core/testing"; import { html } from "../../../support/formatting"; -import { defaults, hidden, renders, t9n } from "../../tests/commonTests"; +import { defaults, focusable, hidden, renders, t9n } from "../../tests/commonTests"; import { skipAnimations } from "../../tests/utils"; -import { dateFromISO } from "../../utils/date"; import { formatTimePart } from "../../utils/time"; describe("calcite-date-picker", () => { @@ -18,6 +17,11 @@ describe("calcite-date-picker", () => { } ])); + it("focusable", async () => + focusable("", { + shadowFocusTargetSelector: "calcite-date-picker-month-header" + })); + const animationDurationInMs = 200; it("fires a calciteDatePickerChange event when changing year in header", async () => { @@ -209,7 +213,8 @@ describe("calcite-date-picker", () => { await page.setContent(""); const date = await page.find("calcite-date-picker"); const changedEvent = await page.spyOnEvent("calciteDatePickerChange"); - await date.setProperty("value", "2001-10-28"); + date.setProperty("value", "2001-10-28"); + await page.waitForChanges(); expect(changedEvent).toHaveReceivedEventTimes(0); }); diff --git a/src/components/date-picker/date-picker.tsx b/src/components/date-picker/date-picker.tsx index 98d556e913c..98af0f05ff0 100644 --- a/src/components/date-picker/date-picker.tsx +++ b/src/components/date-picker/date-picker.tsx @@ -6,6 +6,7 @@ import { EventEmitter, h, Host, + Method, Prop, State, VNode, @@ -19,6 +20,12 @@ import { HoverRange, setEndOfDay } from "../../utils/date"; +import { + componentLoaded, + LoadableComponent, + setComponentLoaded, + setUpLoadableComponent +} from "../../utils/loadable"; import { connectLocalized, disconnectLocalized, @@ -46,7 +53,7 @@ import { DateLocaleData, getLocaleData, getValueAsDateRange } from "./utils"; delegatesFocus: true } }) -export class DatePicker implements LocalizedComponent, T9nComponent { +export class DatePicker implements LocalizedComponent, LoadableComponent, T9nComponent { //-------------------------------------------------------------------------- // // Element @@ -188,6 +195,19 @@ export class DatePicker implements LocalizedComponent, T9nComponent { @State() endAsDate: Date; + //-------------------------------------------------------------------------- + // + // Public Methods + // + //-------------------------------------------------------------------------- + + /** Sets focus on the component's first focusable element. */ + @Method() + async setFocus(): Promise { + await componentLoaded(this); + this.el.focus(); + } + // -------------------------------------------------------------------------- // // Lifecycle @@ -218,12 +238,17 @@ export class DatePicker implements LocalizedComponent, T9nComponent { } async componentWillLoad(): Promise { + setUpLoadableComponent(this); await this.loadLocaleData(); this.onMinChanged(this.min); this.onMaxChanged(this.max); await setUpMessages(this); } + componentDidLoad(): void { + setComponentLoaded(this); + } + render(): VNode { const date = dateFromRange( this.range && Array.isArray(this.valueAsDate) ? this.valueAsDate[0] : this.valueAsDate, diff --git a/src/components/dropdown/dropdown.e2e.ts b/src/components/dropdown/dropdown.e2e.ts index 8781da42265..3b2c8014f5c 100644 --- a/src/components/dropdown/dropdown.e2e.ts +++ b/src/components/dropdown/dropdown.e2e.ts @@ -1,23 +1,26 @@ import { E2EPage, newE2EPage } from "@stencil/core/testing"; import dedent from "dedent"; import { html } from "../../../support/formatting"; -import { accessible, defaults, disabled, floatingUIOwner, hidden, renders } from "../../tests/commonTests"; +import { focusable, accessible, defaults, disabled, floatingUIOwner, hidden, renders } from "../../tests/commonTests"; import { GlobalTestProps } from "../../tests/utils"; import { CSS } from "./resources"; +const simpleDropdownHTML = html` + Open dropdown + + Dropdown Item Content + Dropdown Item Content + Dropdown Item Content + +`; + describe("calcite-dropdown", () => { - it("renders", () => - renders( - html` - Open dropdown - - Dropdown Item Content - Dropdown Item Content - Dropdown Item Content - - `, - { display: "inline-flex" } - )); + it("focusable", async () => + focusable(simpleDropdownHTML, { + focusTargetSelector: '[slot="trigger"]' + })); + + it("renders", () => renders(simpleDropdownHTML, { display: "inline-flex" })); it("honors hidden attribute", async () => hidden("calcite-dropdown")); @@ -33,18 +36,7 @@ describe("calcite-dropdown", () => { } ])); - it("can be disabled", () => - disabled( - html` - Open dropdown - - Dropdown Item Content - Dropdown Item Content - Dropdown Item Content - - `, - { focusTarget: "child" } - )); + it("can be disabled", () => disabled(simpleDropdownHTML, { focusTarget: "child" })); interface SelectedItemsAssertionOptions { /** @@ -748,7 +740,7 @@ describe("calcite-dropdown", () => { expect(calciteDropdownOpen).toHaveReceivedEventTimes(1); expect(calciteDropdownClose).toHaveReceivedEventTimes(0); - await trigger.focus(); + await element.callMethod("setFocus"); await page.keyboard.press("Space"); await page.waitForChanges(); expect(await dropdownWrapper.isVisible()).toBe(false); @@ -793,7 +785,7 @@ describe("calcite-dropdown", () => { expect(calciteDropdownOpen).toHaveReceivedEventTimes(1); expect(calciteDropdownClose).toHaveReceivedEventTimes(0); - await trigger.focus(); + await element.callMethod("setFocus"); await page.keyboard.press("Space"); await page.waitForChanges(); expect(await dropdownWrapper.isVisible()).toBe(false); diff --git a/src/components/dropdown/dropdown.tsx b/src/components/dropdown/dropdown.tsx index b77baeb4b0c..cd08c4ca431 100644 --- a/src/components/dropdown/dropdown.tsx +++ b/src/components/dropdown/dropdown.tsx @@ -35,6 +35,12 @@ import { import { guid } from "../../utils/guid"; import { InteractiveComponent, updateHostInteraction } from "../../utils/interactive"; import { isActivationKey } from "../../utils/key"; +import { + componentLoaded, + LoadableComponent, + setComponentLoaded, + setUpLoadableComponent +} from "../../utils/loadable"; import { createObserver } from "../../utils/observers"; import { connectOpenCloseComponent, @@ -56,7 +62,9 @@ import { SLOTS } from "./resources"; delegatesFocus: true } }) -export class Dropdown implements InteractiveComponent, OpenCloseComponent, FloatingUIComponent { +export class Dropdown + implements InteractiveComponent, LoadableComponent, OpenCloseComponent, FloatingUIComponent +{ //-------------------------------------------------------------------------- // // Element @@ -185,6 +193,19 @@ export class Dropdown implements InteractiveComponent, OpenCloseComponent, Float */ @Prop({ reflect: true }) width: Scale; + //-------------------------------------------------------------------------- + // + // Public Methods + // + //-------------------------------------------------------------------------- + + /** Sets focus on the component's first focusable element. */ + @Method() + async setFocus(): Promise { + await componentLoaded(this); + this.el.focus(); + } + //-------------------------------------------------------------------------- // // Lifecycle @@ -201,7 +222,12 @@ export class Dropdown implements InteractiveComponent, OpenCloseComponent, Float connectOpenCloseComponent(this); } + componentWillLoad(): void { + setUpLoadableComponent(this); + } + componentDidLoad(): void { + setComponentLoaded(this); this.reposition(true); } diff --git a/src/components/pagination/pagination.e2e.ts b/src/components/pagination/pagination.e2e.ts index bea94d99450..558fcf8369e 100644 --- a/src/components/pagination/pagination.e2e.ts +++ b/src/components/pagination/pagination.e2e.ts @@ -1,11 +1,21 @@ -import { newE2EPage, E2EElement, E2EPage } from "@stencil/core/testing"; -import { accessible, hidden, renders, t9n } from "../../tests/commonTests"; -import { CSS } from "./resources"; +import { E2EElement, E2EPage, newE2EPage } from "@stencil/core/testing"; import { html } from "../../../support/formatting"; +import { accessible, focusable, hidden, renders, t9n } from "../../tests/commonTests"; +import { CSS } from "./resources"; describe("calcite-pagination", () => { it("renders", async () => renders("calcite-pagination", { display: "flex" })); + it("focuses previous button when not on the first page", async () => + focusable('', { + shadowFocusTargetSelector: `.${CSS.previous}` + })); + + it("focuses page number 1 when on the first page", async () => + focusable('', { + shadowFocusTargetSelector: `.${CSS.page}` + })); + it("honors hidden attribute", async () => hidden("calcite-pagination")); it("is accessible", async () => accessible(``)); diff --git a/src/components/pagination/pagination.tsx b/src/components/pagination/pagination.tsx index 144afc99417..773eeeba7dd 100644 --- a/src/components/pagination/pagination.tsx +++ b/src/components/pagination/pagination.tsx @@ -11,6 +11,12 @@ import { VNode, Watch } from "@stencil/core"; +import { + componentLoaded, + LoadableComponent, + setComponentLoaded, + setUpLoadableComponent +} from "../../utils/loadable"; import { connectLocalized, disconnectLocalized, @@ -44,7 +50,9 @@ export interface PaginationDetail { }, assetsDirs: ["assets"] }) -export class Pagination implements LocalizedComponent, LocalizedComponent, T9nComponent { +export class Pagination + implements LocalizedComponent, LocalizedComponent, LoadableComponent, T9nComponent +{ //-------------------------------------------------------------------------- // // Public Properties @@ -145,6 +153,11 @@ export class Pagination implements LocalizedComponent, LocalizedComponent, T9nCo async componentWillLoad(): Promise { await setUpMessages(this); + setUpLoadableComponent(this); + } + + componentDidLoad(): void { + setComponentLoaded(this); } disconnectedCallback(): void { @@ -158,6 +171,13 @@ export class Pagination implements LocalizedComponent, LocalizedComponent, T9nCo // // -------------------------------------------------------------------------- + /** Sets focus on the component's first focusable element. */ + @Method() + async setFocus(): Promise { + await componentLoaded(this); + this.el.focus(); + } + /** Go to the next page of results. */ @Method() async nextPage(): Promise { diff --git a/src/components/split-button/split-button.e2e.ts b/src/components/split-button/split-button.e2e.ts index a649700f3b0..8042d663811 100644 --- a/src/components/split-button/split-button.e2e.ts +++ b/src/components/split-button/split-button.e2e.ts @@ -1,6 +1,6 @@ import { newE2EPage } from "@stencil/core/testing"; -import { accessible, renders, defaults, disabled, hidden } from "../../tests/commonTests"; import { html } from "../../../support/formatting"; +import { accessible, defaults, disabled, focusable, hidden, renders } from "../../tests/commonTests"; import { CSS } from "./resources"; describe("calcite-split-button", () => { @@ -22,6 +22,16 @@ describe("calcite-split-button", () => { it("honors hidden attribute", async () => hidden("calcite-split-button")); + it("focusable", async () => + focusable( + ` + ${content} + `, + { + shadowFocusTargetSelector: "calcite-button" + } + )); + it("is accessible", async () => accessible(`; + //-------------------------------------------------------------------------- + // + // Public Methods + // + //-------------------------------------------------------------------------- + + /** Sets focus on the component's first focusable element. */ + @Method() + async setFocus(): Promise { + await componentLoaded(this); + this.el.focus(); + } + //-------------------------------------------------------------------------- // // Lifecycle // //-------------------------------------------------------------------------- + componentWillLoad(): void { + setUpLoadableComponent(this); + } + + componentDidLoad(): void { + setComponentLoaded(this); + } + componentDidRender(): void { updateHostInteraction(this); } diff --git a/src/tests/commonTests.ts b/src/tests/commonTests.ts index 085e53bd076..89289c65286 100644 --- a/src/tests/commonTests.ts +++ b/src/tests/commonTests.ts @@ -1,12 +1,12 @@ import { E2EElement, E2EPage, newE2EPage } from "@stencil/core/testing"; -import { JSX } from "../components"; -import { toHaveNoViolations } from "jest-axe"; import axe from "axe-core"; +import { toHaveNoViolations } from "jest-axe"; import { config } from "../../stencil.config"; -import { GlobalTestProps, skipAnimations } from "./utils"; -import { hiddenFormInputSlotName } from "../utils/form"; import { html } from "../../support/formatting"; +import { JSX } from "../components"; +import { hiddenFormInputSlotName } from "../utils/form"; import { MessageBundle } from "../utils/t9n"; +import { GlobalTestProps, skipAnimations } from "./utils"; expect.extend(toHaveNoViolations); @@ -213,14 +213,13 @@ export async function focusable(componentTagOrHTML: TagOrHTML, options?: Focusab const tag = getTag(componentTagOrHTML); const element = await page.find(tag); const focusTargetSelector = options?.focusTargetSelector || tag; - await element.callMethod("setFocus", options?.focusId); // assumes element is FocusableElement if (options?.shadowFocusTargetSelector) { expect( await page.$eval( tag, - (element: HTMLElement, selector: string) => element.shadowRoot.activeElement.matches(selector), + (element: HTMLElement, selector: string) => element.shadowRoot.activeElement?.matches(selector), options?.shadowFocusTargetSelector ) ).toBe(true); @@ -229,7 +228,7 @@ export async function focusable(componentTagOrHTML: TagOrHTML, options?: Focusab // wait for next frame before checking focus await page.waitForTimeout(0); - expect(await page.evaluate((selector) => document.activeElement.matches(selector), focusTargetSelector)).toBe(true); + expect(await page.evaluate((selector) => document.activeElement?.matches(selector), focusTargetSelector)).toBe(true); } /**