diff --git a/packages/calcite-components/conventions/README.md b/packages/calcite-components/conventions/README.md index f86368d715d..bff430b437f 100644 --- a/packages/calcite-components/conventions/README.md +++ b/packages/calcite-components/conventions/README.md @@ -409,3 +409,33 @@ The [`globalAttributes`](../src/utils/globalAttributes.ts) util was specifically ### BigDecimal `BigDecimal` is a [number util](https://github.com/Esri/calcite-design-system/blob/main/packages/calcite-components/src/utils/number.ts) that helps with [arbitrary precision arithmetic](https://en.wikipedia.org/wiki/Arbitrary-precision_arithmetic). The util is adopted from a [Stack Overflow answer](https://stackoverflow.com/a/66939244) with some small changes. There are some usage examples in [`number.spec.ts`](../src/utils/number.spec.ts). + +### Custom child element support + +In order to support certain architectures, parent components might need to handle custom elements that wrap their expected child items within shadow DOM that would prevent discovery when querying the DOM. + +For such cases, the following pattern will enable developers to create custom child/item components and have them work seamlessly with parent components. + +#### Parent component + +- Must provide a `customItemSelectors` property to allow querying for custom elements in addition to their expected children. +- An interface for `HTMLElement` must be created in the parent's `interfaces.d.ts` file, where the necessary child APIs must be extracted. + **`parent/interfaces.d.ts`** + ```ts + type ChildComponentLike = Pick & HTMLElement; + ``` + **`parent/parent.tsx`** + ```tsx + @Prop() selectedItem: HTMLChildComponentElement | ChildComponentLike; + ``` + +#### Custom child component + +- Must implement the element interface expected by the parent (e.g., `ChildComponentLike`). + +#### Notes + +- This pattern should be applied sparingly and on a case-by-case basis. +- We can refine this pattern as we go on, but additional modifications needed to handle the custom items workflow will be considered out-of-scope and thus not supported. +- Until we have documentation covering creating custom elements, `customItemSelectors` must be made internal and any `ChildComponentLike` types must be excluded from the doc. +- Please refer to https://github.com/Esri/calcite-design-system/pull/7608/ as an example on how this pattern is applied. diff --git a/packages/calcite-components/src/components/flow/flow.e2e.ts b/packages/calcite-components/src/components/flow/flow.e2e.ts index 34b4239495c..ce2f649becd 100755 --- a/packages/calcite-components/src/components/flow/flow.e2e.ts +++ b/packages/calcite-components/src/components/flow/flow.e2e.ts @@ -5,6 +5,7 @@ import { accessible, focusable, hidden, renders } from "../../tests/commonTests" import { CSS as ITEM_CSS } from "../flow-item/resources"; import { CSS } from "./resources"; import { isElementFocused } from "../../tests/utils"; +import { FlowItemLikeElement } from "./interfaces"; describe("calcite-flow", () => { describe("renders", () => { @@ -339,4 +340,137 @@ describe("calcite-flow", () => { expect(await items[4].getProperty("showBackButton")).toBe(false); }); }); + + it("supports custom flow-items", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + +

😃

+
+ +

🥸

+
+ +

😃

+
+
+ `); + + await page.evaluate(async () => { + class CustomFlowItem extends HTMLElement implements FlowItemLikeElement { + private flowItemEl: HTMLCalciteFlowItemElement; + + constructor() { + super(); + const shadow = this.attachShadow({ mode: "open" }); + + shadow.innerHTML = ` + + + + + `; + + this.flowItemEl = shadow.getElementById("internalFlowItem") as HTMLCalciteFlowItemElement; + } + + connectedCallback(): void { + this.flowItemEl.setAttribute("heading", this.getAttribute("heading")); + this.flowItemEl.setAttribute("show-back-button", this.getAttribute("show-back-button")); + this.flowItemEl.setAttribute("menu-open", this.getAttribute("menu-open")); + } + + get heading(): string { + return this.getAttribute("heading"); + } + + set heading(value: string) { + this.flowItemEl.heading = value; + } + + get hidden(): boolean { + return this.hasAttribute("hidden"); + } + + set hidden(value: boolean) { + this.toggleAttribute("hidden", value); + this.flowItemEl.toggleAttribute("hidden", value); + } + + get menuOpen(): boolean { + return this.hasAttribute("menu-open"); + } + + set menuOpen(value: boolean) { + this.toggleAttribute("menu-open", value); + this.flowItemEl.menuOpen = value; + } + + get showBackButton(): boolean { + return this.hasAttribute("show-back-button"); + } + + set showBackButton(value: boolean) { + this.toggleAttribute("show-back-button", value); + this.flowItemEl.showBackButton = value; + } + + async beforeBack(): Promise { + // no op + } + + async setFocus(): Promise { + await this.flowItemEl.setFocus(); + } + } + + customElements.define("custom-flow-item", CustomFlowItem); + }); + + const flow = await page.find("calcite-flow"); + const displayedItemSelector = "calcite-flow > *:not([hidden])"; + let displayedItem = await page.find(displayedItemSelector); + + expect(await flow.getProperty("childElementCount")).toBe(3); + expect(displayedItem.id).toBe("third"); + + await page.evaluate( + async (displayedItemSelector: string, ITEM_CSS) => { + document + .querySelector(displayedItemSelector) + .shadowRoot.querySelector(`.${ITEM_CSS.backButton}`) + .click(); + }, + displayedItemSelector, + ITEM_CSS + ); + await page.waitForChanges(); + + displayedItem = await page.find(displayedItemSelector); + expect(await flow.getProperty("childElementCount")).toBe(2); + expect(displayedItem.id).toBe("second"); + + await page.evaluate( + async (displayedItemSelector: string, ITEM_CSS) => { + document + .querySelector(displayedItemSelector) + .shadowRoot.querySelector("calcite-flow-item") + .shadowRoot.querySelector(`.${ITEM_CSS.backButton}`) + .click(); + }, + displayedItemSelector, + ITEM_CSS + ); + await page.waitForChanges(); + + displayedItem = await page.find(displayedItemSelector); + expect(await flow.getProperty("childElementCount")).toBe(1); + expect(displayedItem.id).toBe("first"); + }); }); diff --git a/packages/calcite-components/src/components/flow/flow.tsx b/packages/calcite-components/src/components/flow/flow.tsx index e4482fdfeda..a15f0f32e5d 100755 --- a/packages/calcite-components/src/components/flow/flow.tsx +++ b/packages/calcite-components/src/components/flow/flow.tsx @@ -1,6 +1,6 @@ -import { Component, Element, h, Listen, Method, State, VNode } from "@stencil/core"; +import { Component, Element, h, Listen, Method, Prop, State, VNode } from "@stencil/core"; import { createObserver } from "../../utils/observers"; -import { FlowDirection } from "./interfaces"; +import { FlowDirection, FlowItemLikeElement } from "./interfaces"; import { CSS } from "./resources"; import { componentFocusable, @@ -28,7 +28,7 @@ export class Flow implements LoadableComponent { * Removes the currently active `calcite-flow-item`. */ @Method() - async back(): Promise { + async back(): Promise { const { items } = this; const lastItem = items[items.length - 1]; @@ -66,6 +66,19 @@ export class Flow implements LoadableComponent { return activeItem?.setFocus(); } + // -------------------------------------------------------------------------- + // + // Public Properties + // + // -------------------------------------------------------------------------- + + /** + * This property enables the component to consider other custom elements implementing flow-item's interface. + * + * @internal + */ + @Prop() customItemSelectors: string; + // -------------------------------------------------------------------------- // // Private Properties @@ -78,7 +91,7 @@ export class Flow implements LoadableComponent { @State() itemCount = 0; - @State() items: HTMLCalciteFlowItemElement[] = []; + @State() items: FlowItemLikeElement[] = []; itemMutationObserver = createObserver("mutation", () => this.updateFlowProps()); @@ -129,11 +142,13 @@ export class Flow implements LoadableComponent { }; updateFlowProps = (): void => { - const { el, items } = this; + const { customItemSelectors, el, items } = this; - const newItems: HTMLCalciteFlowItemElement[] = Array.from( - el.querySelectorAll("calcite-flow-item") - ).filter((flowItem) => flowItem.closest("calcite-flow") === el) as HTMLCalciteFlowItemElement[]; + const newItems = Array.from( + el.querySelectorAll( + `calcite-flow-item${customItemSelectors ? `,${customItemSelectors}` : ""}` + ) + ).filter((flowItem) => flowItem.closest("calcite-flow") === el); const oldItemCount = items.length; const newItemCount = newItems.length; diff --git a/packages/calcite-components/src/components/flow/interfaces.ts b/packages/calcite-components/src/components/flow/interfaces.ts index 41a9a242584..be33fbfcbb8 100644 --- a/packages/calcite-components/src/components/flow/interfaces.ts +++ b/packages/calcite-components/src/components/flow/interfaces.ts @@ -1 +1,7 @@ export type FlowDirection = "advancing" | "retreating"; + +export type FlowItemLikeElement = Pick< + HTMLCalciteFlowItemElement, + "beforeBack" | "menuOpen" | "setFocus" | "showBackButton" +> & + HTMLElement; diff --git a/packages/calcite-components/src/demos/flow.html b/packages/calcite-components/src/demos/flow.html index 69068fd4ab5..336ffafd7eb 100644 --- a/packages/calcite-components/src/demos/flow.html +++ b/packages/calcite-components/src/demos/flow.html @@ -34,94 +34,93 @@ -
-
basic
+
custom flow-item support
- - - + + + - - -
test
+ + + + +
-
-
+