Skip to content

Commit

Permalink
feat(flow): add support for custom flow-item elements (#7608)
Browse files Browse the repository at this point in the history
**Related Issue:** #6237 

## Summary

This enables `flow` to work with custom components using shadow DOM
wrapping `flow-item`.

Custom components will have to implement the
[`FlowItemLike`](https://github.com/Esri/calcite-design-system/pull/7608/files#diff-82c222ab365cde13a1f1288d936611519dfd9bee1e283164b260ca554c04a191R3-R7)
interface and set the new `custom-item-selectors`/`customItemSelectors`
attr/prop to target custom components (see [E2E
test](https://github.com/Esri/calcite-design-system/pull/7608/files#diff-9b86e64de24dfca441533c63ae0f6834bff10bffbde23fd8bb3989a2259e356cR315-R387)
for vanilla JS example).

**Note**: `customItemSelectors` is intentionally marked internal since
we don't have any documentation yet on developing custom components,
which this is meant to support. We could additionally hide
`FlowItemLike` in the doc site to avoid confusion until we have more
documentation on this use case. I'm open to suggestions on this.
@geospatialem @macandcheese @driskull
  • Loading branch information
jcfranco authored Sep 1, 2023
1 parent 38be8f8 commit 197adfe
Show file tree
Hide file tree
Showing 5 changed files with 270 additions and 86 deletions.
30 changes: 30 additions & 0 deletions packages/calcite-components/conventions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `HTML<ChildComponentItem>Element` 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<HTMLCalciteChildElement, "required" | "props" | "from" | "parent"> & 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.
134 changes: 134 additions & 0 deletions packages/calcite-components/src/components/flow/flow.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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`
<calcite-flow custom-item-selectors="custom-flow-item">
<calcite-flow-item heading="flow-item-1" id="first">
<p>😃</p>
</calcite-flow-item>
<custom-flow-item heading="custom-flow-item" id="second">
<p>🥸</p>
</custom-flow-item>
<calcite-flow-item heading="flow-item-2" id="third">
<p>😃</p>
</calcite-flow-item>
</calcite-flow>
`);

await page.evaluate(async () => {
class CustomFlowItem extends HTMLElement implements FlowItemLikeElement {
private flowItemEl: HTMLCalciteFlowItemElement;

constructor() {
super();
const shadow = this.attachShadow({ mode: "open" });

shadow.innerHTML = `
<style>
:host {
display: flex;
background: #bdf2c4;
}
</style>
<calcite-flow-item id="internalFlowItem">
<slot></slot>
</calcite-flow-item>
`;

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<void> {
// no op
}

async setFocus(): Promise<void> {
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<HTMLCalciteActionElement>(`.${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<HTMLCalciteActionElement>(`.${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");
});
});
31 changes: 23 additions & 8 deletions packages/calcite-components/src/components/flow/flow.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -28,7 +28,7 @@ export class Flow implements LoadableComponent {
* Removes the currently active `calcite-flow-item`.
*/
@Method()
async back(): Promise<HTMLCalciteFlowItemElement> {
async back(): Promise<HTMLCalciteFlowItemElement | FlowItemLikeElement> {
const { items } = this;

const lastItem = items[items.length - 1];
Expand Down Expand Up @@ -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
Expand All @@ -78,7 +91,7 @@ export class Flow implements LoadableComponent {

@State() itemCount = 0;

@State() items: HTMLCalciteFlowItemElement[] = [];
@State() items: FlowItemLikeElement[] = [];

itemMutationObserver = createObserver("mutation", () => this.updateFlowProps());

Expand Down Expand Up @@ -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<FlowItemLikeElement>(
el.querySelectorAll(
`calcite-flow-item${customItemSelectors ? `,${customItemSelectors}` : ""}`
)
).filter((flowItem) => flowItem.closest("calcite-flow") === el);

const oldItemCount = items.length;
const newItemCount = newItems.length;
Expand Down
6 changes: 6 additions & 0 deletions packages/calcite-components/src/components/flow/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
export type FlowDirection = "advancing" | "retreating";

export type FlowItemLikeElement = Pick<
HTMLCalciteFlowItemElement,
"beforeBack" | "menuOpen" | "setFocus" | "showBackButton"
> &
HTMLElement;
Loading

0 comments on commit 197adfe

Please sign in to comment.