diff --git a/src/components/Stepper/Step/Step.scss b/src/components/Stepper/Step/Step.scss new file mode 100644 index 00000000..abe23df8 --- /dev/null +++ b/src/components/Stepper/Step/Step.scss @@ -0,0 +1,115 @@ +@import "vanilla-framework"; + +.p-list__item { + padding-bottom: 0; + padding-top: 0; +} + +.p-inline-list { + display: flex; +} + +.p-inline-list__item { + margin: 0; +} + +.step-number { + border: 0.08rem solid black; + border-radius: 1rem; + height: 1.4rem; + line-height: 1.3; + margin-left: $sph--small; + margin-right: 0.1rem; + margin-top: 0.1rem; + text-align: center; + width: 1.4rem; +} + +.step-number-disabled { + border: 0.08rem solid #757575; + color: #757575; +} + +.step-content { + display: flex; + flex: 1; + flex-direction: column; + margin-left: $sph--small; +} + +.step-enabled:hover { + cursor: pointer; + text-decoration: underline; +} + +.step-disabled { + color: #757575; + pointer-events: none; +} + +.step-status-icon { + height: 1.6rem; + margin-left: 0.4rem; + width: 1.6rem; +} + +.step-selected { + background-color: var(--vf-color-background-alt); +} + +.step-optional-content { + font-size: 12px; + max-width: 10rem; +} + +.stepper-horizontal { + .step { + border-top: 0.2rem solid var(--vf-color-border-default); + display: flex; + height: 100%; + padding: 0.4rem $spv--medium; + width: fit-content; + } + + .step-status-icon { + margin-left: 0; + } + + .step-number { + margin-left: 0; + } + + .step-content { + max-width: 10rem; + } + + .progress-line { + border-top: 0.2rem solid black; + } + + :first-child .step { + padding-left: 0; + } +} + +.stepper-vertical { + .step { + border-left: 0.2rem solid var(--vf-color-border-default); + display: flex; + padding: $spv--medium 0; + padding-right: 0.5rem; + width: fit-content; + } + + .progress-line { + border-left: 0.2rem solid black; + } + + :first-child .step { + padding-top: 0; + } + + :last-child .step { + padding-bottom: 0; + } +} diff --git a/src/components/Stepper/Step/Step.stories.tsx b/src/components/Stepper/Step/Step.stories.tsx new file mode 100644 index 00000000..a9c2701f --- /dev/null +++ b/src/components/Stepper/Step/Step.stories.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { Meta, StoryObj } from "@storybook/react"; +import Step from "./Step"; +import Stepper from "../Stepper"; + +const meta: Meta = { + component: Step, + render: (args) => ( + ]} /> + ), + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: "Default", + + args: { + title: "Step 1", + index: 1, + enabled: false, + hasProgressLine: false, + iconName: "number", + handleClick: () => {}, + }, +}; diff --git a/src/components/Stepper/Step/Step.test.tsx b/src/components/Stepper/Step/Step.test.tsx new file mode 100644 index 00000000..fb483e84 --- /dev/null +++ b/src/components/Stepper/Step/Step.test.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import Step from "./Step"; +import type { Props } from "./Step"; + +describe("Step component", () => { + const props: Props = { + hasProgressLine: true, + index: 1, + title: "Title", + enabled: true, + iconName: "number", + handleClick: jest.fn(), + }; + + it("renders the step with the required props", () => { + render(); + expect(screen.getByText("Title")).toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + expect(document.querySelector(".progress-line")).toBeInTheDocument(); + }); + + it("can display icon", () => { + render(); + expect(document.querySelector(".p-icon--success")).toBeInTheDocument(); + }); + + it("can disable the step", () => { + render(); + expect(screen.getByText("Title")).toHaveClass("step-disabled"); + }); + + it("can select the step", () => { + render(); + expect(document.querySelector(".step-selected")).toBeInTheDocument(); + }); + + it("can call handleClick when clicked", async () => { + render(); + await userEvent.click(screen.getByText("Title")); + expect(props.handleClick).toHaveBeenCalled(); + }); + + it("can display optional label", () => { + render(); + + expect(screen.getByText("Optional label")).toBeInTheDocument(); + }); + + it("can display optional link", () => { + const linkProps = { + href: "/test-link", + children: "Link", + }; + render(); + const linkElement = screen.getByRole("link", { name: "Link" }); + expect(linkElement).toBeInTheDocument(); + expect(linkElement).toHaveAttribute("href", "/test-link"); + }); +}); diff --git a/src/components/Stepper/Step/Step.tsx b/src/components/Stepper/Step/Step.tsx new file mode 100644 index 00000000..2477e435 --- /dev/null +++ b/src/components/Stepper/Step/Step.tsx @@ -0,0 +1,118 @@ +import classNames from "classnames"; +import React from "react"; +import Icon from "components/Icon"; +import Link, { LinkProps } from "components/Link"; +import { ClassName } from "types"; +import "./Step.scss"; + +export type Props = { + /** + * Whether the step has a darkened progress line. + */ + hasProgressLine: boolean; + /** + * Index of the step. + */ + index: number; + /** + * Title of the step. + */ + title: string; + /** + * Optional label for the step. + */ + label?: string; + /** + * Optional props to configure the `Link` component. + */ + linkProps?: LinkProps; + /** + * Whether the step is clickable. If set to false, the step is not clickable and the text is muted with a light-dark colour. + */ + enabled: boolean; + /** + * Optional value to highlight the selected step. + */ + selected?: boolean; + /** + * Icon to display in the step. Specify "number" if the index should be displayed. + */ + iconName: string; + /** + * Optional class(es) to pass to the Icon component. + */ + iconClassName?: ClassName; + /** + * Function that is called when the step is clicked. + */ + handleClick: () => void; +}; + +const Step = ({ + hasProgressLine, + index, + title, + label, + linkProps, + enabled, + selected = false, + iconName, + iconClassName, + handleClick, + ...props +}: Props): JSX.Element => { + const stepStatusClass = enabled ? "step-enabled" : "step-disabled"; + + return ( +
+ {iconName === "number" ? ( + + {index} + + ) : ( + + )} +
+ + {title} + + {label && ( + + {label} + + )} + {linkProps && ( + + {linkProps.children} + + )} +
+
+ ); +}; + +export default Step; diff --git a/src/components/Stepper/Step/index.tsx b/src/components/Stepper/Step/index.tsx new file mode 100644 index 00000000..cbecee7f --- /dev/null +++ b/src/components/Stepper/Step/index.tsx @@ -0,0 +1,2 @@ +export { default } from "./Step"; +export type { Props as StepProps } from "./Step"; diff --git a/src/components/Stepper/Stepper.stories.tsx b/src/components/Stepper/Stepper.stories.tsx new file mode 100644 index 00000000..b6dfcf1a --- /dev/null +++ b/src/components/Stepper/Stepper.stories.tsx @@ -0,0 +1,318 @@ +import React from "react"; +import { Meta, StoryObj } from "@storybook/react"; +import Stepper from "./Stepper"; +import Step from "./Step/Step"; + +const meta: Meta = { + component: Stepper, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + name: "Default", + + args: { + variant: "vertical", + steps: [ + {}} + />, + {}} + />, + {}} + />, + {}} + />, + ], + }, +}; + +export const HorizontalStepper: Story = { + name: "Horizontal Stepper", + render: () => ( + {}} + />, + {}} + />, + {}} + />, + {}} + />, + ]} + /> + ), +}; + +export const VerticalOptional: Story = { + name: "Vertical variant: Optional labels and links", + render: () => ( + {}} + />, + {}} + label="Optional label" + />, + {}} + />, + {}} + />, + ]} + /> + ), +}; + +export const HorizontalOptional: Story = { + name: "Horizontal variant: Optional labels and links", + render: () => ( + {}} + />, + {}} + label="Optional label" + />, + {}} + />, + {}} + />, + ]} + /> + ), +}; + +export const VerticalSelected: Story = { + name: "Vertical variant: Selected step", + render: () => ( + {}} + />, + {}} + label="Optional label" + selected={true} + />, + {}} + />, + {}} + />, + ]} + /> + ), +}; + +export const HorizontalSelected: Story = { + name: "Horizontal variant: Selected step", + render: () => ( + {}} + />, + {}} + label="Optional label" + />, + {}} + />, + {}} + />, + ]} + /> + ), +}; diff --git a/src/components/Stepper/Stepper.test.tsx b/src/components/Stepper/Stepper.test.tsx new file mode 100644 index 00000000..81ff640d --- /dev/null +++ b/src/components/Stepper/Stepper.test.tsx @@ -0,0 +1,65 @@ +import React from "react"; +import { render, screen } from "@testing-library/react"; +import Step from "./Step"; +import Stepper from "./Stepper"; + +describe("Step component", () => { + const steps = [ + {}} + />, + {}} + label="Optional label" + />, + {}} + />, + {}} + />, + ]; + + it("renders the stepper", () => { + render(); + expect(screen.getByRole("list")).toMatchSnapshot(); + }); + + it("can be a horizontal stepper", () => { + render(); + expect(document.querySelector(".stepper-horizontal")).toBeInTheDocument(); + }); + + it("can be a vertical stepper", () => { + render(); + expect(document.querySelector(".stepper-vertical")).toBeInTheDocument(); + }); +}); diff --git a/src/components/Stepper/Stepper.tsx b/src/components/Stepper/Stepper.tsx new file mode 100644 index 00000000..530053d7 --- /dev/null +++ b/src/components/Stepper/Stepper.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import List from "components/List"; +import { StepProps } from "./Step"; +import classNames from "classnames"; + +export type Props = { + /** + * Optional value that defines the orientation of the stepper. Can either be "horizontal" or "vertical". If not specified, it defaults to "vertical". + */ + variant?: "horizontal" | "vertical"; + /** + * A list of steps. + */ + steps: React.ReactElement[]; +}; + +/** + * This is a stepper component that is used to guide users through a series of sequential steps, providing a clear start and end point. It helps users understand their current position in the process and anticipate upcoming actions. The stepper component should accept a list of Step components for the steps. + */ + +const Stepper = ({ variant = "vertical", steps }: Props): JSX.Element => { + return ( + + ); +}; + +export default Stepper; diff --git a/src/components/Stepper/__snapshots__/Stepper.test.tsx.snap b/src/components/Stepper/__snapshots__/Stepper.test.tsx.snap new file mode 100644 index 00000000..b30964bf --- /dev/null +++ b/src/components/Stepper/__snapshots__/Stepper.test.tsx.snap @@ -0,0 +1,119 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Step component renders the stepper 1`] = ` +
    +
  • +
    + +
    + + Step 1 + + + Optional label + +
    +
    +
  • +
  • +
    + +
    + + Step 2 + + + Optional label + +
    +
    +
  • +
  • +
    + + 3 + +
    + + Step 3 + + + Optional label + + + Optional link + +
    +
    +
  • +
  • +
    + + 4 + +
    + + Step 4 + + + Optional link + +
    +
    +
  • +
+`; diff --git a/src/components/Stepper/index.ts b/src/components/Stepper/index.ts new file mode 100644 index 00000000..407240b7 --- /dev/null +++ b/src/components/Stepper/index.ts @@ -0,0 +1,2 @@ +export { default, type Props as StepperProps } from "./Stepper"; +export { default as Step } from "./Step";