diff --git a/.changeset/tender-ads-learn.md b/.changeset/tender-ads-learn.md new file mode 100644 index 00000000000..617b3048ac2 --- /dev/null +++ b/.changeset/tender-ads-learn.md @@ -0,0 +1,167 @@ +--- +"@salt-ds/core": patch +"@salt-ds/lab": patch +--- + +Stepper, Step and useSteppedReducer + +We are exited to introduce a new (sort of) salt component, the `Stepper`, currently in lab for you to test out and give feedback. It serves as a replacement to the current `SteppedTracker`. + +The `Stepper` is a component that helps you manage a series of steps in a process. It provides a way to navigate between steps, and to track the progress of the process. + +The `` is meant to be used in conjunction with it's buddies, the `` component and the `useSteppedReducer()` hook. + +In it's simples form the `Stepper` can be used like so: + +```tsx +import { Stepper, Step } from "@salt-ds/lab"; + +function Example() { + return ( + + + + + + ); +} +``` + +The Stepper component supports nested steps, which can be used to represent sub-steps within a step. This can be done by nesting `` components within another `` component. We advise you not to go above 2 levels deep, as it becomes hard to follow for the user. + +```tsx +import { StackLayout } from "@salt-ds/core"; +import { Step, Stepper } from "@salt-ds/lab"; + +export const NestedSteps = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; +``` + +The `Stepper` component is a purely presentational component, meaning that you need to manage the state of the steps yourself. That however becomes tricky when dealing with nested steps. This is where the `useSteppedReducer()` hook comes in. It is a custom hook that helps you manage the state of a stepper component with nested steps with ease. It has a built-in algorithm that determine the stage of all steps above and below the active step. All you need to do is add `stage: 'active'` to the desired step (see `step-1-3-1`), the hook will figure out the rest. This is what we call `autoStage`. + +The `useSteppedReducer()` is used like so: + +```tsx +import { StackLayout, SegmentedButtonGroup, Button } from "@salt-ds/core"; +import { Step, Stepper, useSteppedReducer } from "@salt-ds/lab"; + +const initialSteps = [ + { + id: "step-1", + label: "Step 1", + defaultExpanded: true, + substeps: [ + { id: "step-1-1", label: "Step 1.1" }, + { id: "step-1-2", label: "Step 1.2" }, + { + id: "step-1-3", + label: "Step 1.3", + defaultExpanded: true, + substeps: [ + { + id: "step-1-3-1", + label: "Step 1.3.1", + stage: "active", + }, + { id: "step-1-3-2", label: "Step 1.3.2" }, + { + id: "step-1-3-3", + label: "Step 1.3.3", + description: "This is just a description text", + }, + ], + }, + { id: "step-1-4", label: "Step 1.4" }, + ], + }, + { id: "step-2", label: "Step 2" }, + { id: "step-3", label: "Step 3" }, +] satisfies Step.Props[]; + +export const Example = () => { + const [state, dispatch] = useSteppedReducer(initialSteps); + + return ( + + + {state.steps.map((step) => ( + + ))} + + + {state.started && ( + + )} + {!state.ended && ( + + )} + + + {state.started && !state.ended && ( + <> + + + + + )} + + + ); +}; +``` diff --git a/packages/core/src/semantic-icon-provider/SemanticIconProvider.tsx b/packages/core/src/semantic-icon-provider/SemanticIconProvider.tsx index c8b19c46142..dd29b050af2 100644 --- a/packages/core/src/semantic-icon-provider/SemanticIconProvider.tsx +++ b/packages/core/src/semantic-icon-provider/SemanticIconProvider.tsx @@ -7,7 +7,9 @@ import { CloseIcon, ErrorSolidIcon, InfoSolidIcon, + LockedIcon, OverflowMenuIcon, + ProgressInprogressIcon, StepActiveIcon, StepDefaultIcon, StepSuccessIcon, @@ -48,6 +50,8 @@ export type SemanticIconMap = { PendingIcon: ElementType; ActiveIcon: ElementType; CompletedIcon: ElementType; + LockedIcon: ElementType; + InProgressIcon: ElementType; }; export interface SemanticIconProviderProps { @@ -84,6 +88,8 @@ const defaultIconMap: SemanticIconMap = { PendingIcon: StepDefaultIcon, ActiveIcon: StepActiveIcon, CompletedIcon: StepSuccessIcon, + LockedIcon: LockedIcon, + InProgressIcon: ProgressInprogressIcon, }; const SemanticIconContext = createContext(defaultIconMap); diff --git a/packages/lab/src/index.ts b/packages/lab/src/index.ts index ec404c772ed..b7361e69518 100644 --- a/packages/lab/src/index.ts +++ b/packages/lab/src/index.ts @@ -61,6 +61,7 @@ export * from "./skip-link"; export * from "./slider"; export * from "./static-list"; export * from "./stepped-tracker"; +export * from "./stepper"; export * from "./stepper-input"; export * from "./system-status"; export * from "./tabs"; diff --git a/packages/lab/src/stepper/Step.Connector.css b/packages/lab/src/stepper/Step.Connector.css new file mode 100644 index 00000000000..b19cdfd8c72 --- /dev/null +++ b/packages/lab/src/stepper/Step.Connector.css @@ -0,0 +1,52 @@ +.saltStepConnector { + --saltStep-connector-borderWidth: 2px; + + grid-area: connector; + min-height: var(--salt-spacing-300); + + transition-duration: inherit; + transition-timing-function: inherit; + transition-property: opacity, min-height; +} + +.saltStepper-horizontal .saltStepConnector { + /* Distance between Icon and Connector */ + --saltStep-connector-horizontal-gap: var(--salt-spacing-100); + + position: absolute; + top: calc(var(--icon-size) / 2 - var(--saltStep-connector-borderWidth)); + left: calc(50% + calc(var(--icon-size) / 2 + var(--saltStep-connector-horizontal-gap))); + right: calc(-50% + calc(var(--icon-size) / 2 + var(--saltStep-connector-horizontal-gap))); + + border-top-width: var(--saltStep-connector-borderWidth); + border-top-style: var(--salt-track-borderStyle-incomplete); + border-top-color: var(--salt-track-borderColor); +} + +.saltStepper-horizontal .saltStep-stage-completed .saltStepConnector { + border-top-style: var(--salt-track-borderStyle-complete); +} + +.saltStepper-vertical .saltStepConnector { + height: 100%; + min-height: 24px; + justify-self: center; + + border-left-width: var(--saltStep-connector-borderWidth); + border-left-style: var(--salt-track-borderStyle-incomplete); + border-left-color: var(--salt-track-borderColor); +} + +.saltStepper-vertical .saltStep-stage-completed .saltStepConnector { + border-left-style: var(--salt-track-borderStyle-complete); +} + +.saltStep-depth-0.saltStep:not(.saltStep-expanded):last-child > .saltStepConnector { + opacity: 0; + min-height: 0; +} + +.saltStep-depth-0.saltStep-expanded:last-child .saltStep:not(.saltStep-expanded):last-child .saltStepConnector { + opacity: 0; + min-height: 0; +} diff --git a/packages/lab/src/stepper/Step.Connector.tsx b/packages/lab/src/stepper/Step.Connector.tsx new file mode 100644 index 00000000000..eb490a36842 --- /dev/null +++ b/packages/lab/src/stepper/Step.Connector.tsx @@ -0,0 +1,26 @@ +import { makePrefixer } from "@salt-ds/core"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import clsx from "clsx"; + +import stepConnectorCSS from "./Step.Connector.css"; + +export namespace StepConnector { + export interface Props { + className?: string; + } +} + +const withBaseName = makePrefixer("saltStepConnector"); + +export function StepConnector({ className }: StepConnector.Props) { + const targetWindow = useWindow(); + + useComponentCssInjection({ + testId: "salt-step-connector", + css: stepConnectorCSS, + window: targetWindow, + }); + + return
; +} diff --git a/packages/lab/src/stepper/Step.Description.css b/packages/lab/src/stepper/Step.Description.css new file mode 100644 index 00000000000..7591e557d6f --- /dev/null +++ b/packages/lab/src/stepper/Step.Description.css @@ -0,0 +1,16 @@ +.saltStepDescription { + grid-area: description; +} + +.saltStepper-vertical .saltStepDescription { + padding-bottom: var(--salt-spacing-300); + padding-left: calc((var(--saltStep-depth)) * var(--salt-spacing-100)); +} + +.saltStep-status-warning > .saltStepDescription { + color: var(--salt-status-warning-foreground-informative); +} + +.saltStep-status-error > .saltStepDescription { + color: var(--salt-status-error-foreground-informative); +} diff --git a/packages/lab/src/stepper/Step.Description.tsx b/packages/lab/src/stepper/Step.Description.tsx new file mode 100644 index 00000000000..dde8f99901b --- /dev/null +++ b/packages/lab/src/stepper/Step.Description.tsx @@ -0,0 +1,36 @@ +import { Text, type TextProps, makePrefixer } from "@salt-ds/core"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import clsx from "clsx"; + +import stepDescriptionCSS from "./Step.Description.css"; + +export namespace StepDescription { + export interface Props extends TextProps<"div"> {} +} + +const withBaseName = makePrefixer("saltStepDescription"); + +export function StepDescription({ + id, + className, + styleAs = "label", + ...props +}: StepDescription.Props) { + const targetWindow = useWindow(); + + useComponentCssInjection({ + testId: "salt-step-description", + css: stepDescriptionCSS, + window: targetWindow, + }); + + return ( + + ); +} diff --git a/packages/lab/src/stepper/Step.ExpandTrigger.css b/packages/lab/src/stepper/Step.ExpandTrigger.css new file mode 100644 index 00000000000..02d49613909 --- /dev/null +++ b/packages/lab/src/stepper/Step.ExpandTrigger.css @@ -0,0 +1,3 @@ +.saltStepExpandTrigger { + grid-area: expand; +} diff --git a/packages/lab/src/stepper/Step.ExpandTrigger.tsx b/packages/lab/src/stepper/Step.ExpandTrigger.tsx new file mode 100644 index 00000000000..d4b87de7b83 --- /dev/null +++ b/packages/lab/src/stepper/Step.ExpandTrigger.tsx @@ -0,0 +1,45 @@ +import { Button, type ButtonProps } from "@salt-ds/core"; +import { makePrefixer } from "@salt-ds/core"; +import { ChevronDownIcon, ChevronUpIcon } from "@salt-ds/icons"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import clsx from "clsx"; + +import stepExpandTriggerCSS from "./Step.ExpandTrigger.css"; + +export namespace StepExpandTrigger { + export interface Props extends ButtonProps { + expanded: boolean; + } +} + +const withBaseName = makePrefixer("saltStepExpandTrigger"); + +export function StepExpandTrigger({ + expanded, + className, + ...props +}: StepExpandTrigger.Props) { + const targetWindow = useWindow(); + + useComponentCssInjection({ + testId: "salt-step-expand-trigger", + css: stepExpandTriggerCSS, + window: targetWindow, + }); + + return ( + + ); +} diff --git a/packages/lab/src/stepper/Step.Icon.css b/packages/lab/src/stepper/Step.Icon.css new file mode 100644 index 00000000000..7f51c9b0841 --- /dev/null +++ b/packages/lab/src/stepper/Step.Icon.css @@ -0,0 +1,40 @@ +.saltStepIcon { + grid-area: icon; + + display: flex; + justify-content: center; + align-items: center; + width: 100%; +} + +.saltStepper-vertical .saltStepIcon { + height: var(--salt-size-base); +} + +.saltStep-stage-pending > .saltStepIcon { + --saltIcon-color: var(--salt-status-static-foreground); +} + +.saltStep-stage-locked > .saltStepIcon { + --saltIcon-color: var(--salt-status-static-foreground); +} + +.saltStep-stage-inprogress > .saltStepIcon { + --saltIcon-color: var(--salt-status-info-foreground-decorative); +} + +.saltStep-stage-active > .saltStepIcon { + --saltIcon-color: var(--salt-status-info-foreground-decorative); +} + +.saltStep-stage-completed > .saltStepIcon { + --saltIcon-color: var(--salt-status-success-foreground-decorative); +} + +.saltStep-status-warning > .saltStepIcon { + --saltIcon-color: var(--salt-status-warning-foreground-decorative); +} + +.saltStep-status-error > .saltStepIcon { + --saltIcon-color: var(--salt-status-error-foreground-decorative); +} diff --git a/packages/lab/src/stepper/Step.Icon.tsx b/packages/lab/src/stepper/Step.Icon.tsx new file mode 100644 index 00000000000..04881b218ba --- /dev/null +++ b/packages/lab/src/stepper/Step.Icon.tsx @@ -0,0 +1,71 @@ +import { makePrefixer, useIcon } from "@salt-ds/core"; +import type { IconProps } from "@salt-ds/icons"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import clsx from "clsx"; + +import type { Step } from "./Step"; +import stepIconCSS from "./Step.Icon.css"; + +export namespace StepIcon { + export interface Props extends IconProps { + stage: Step.Stage; + status?: Step.Status; + multiplier?: IconProps["size"]; + } +} + +const withBaseName = makePrefixer("saltStepIcon"); + +export function StepIcon({ + status, + stage, + size, + multiplier = size || 1.5, + className, + ...props +}: StepIcon.Props) { + const targetWindow = useWindow(); + const IconComponent = useStepIcon({ stage, status }); + + useComponentCssInjection({ + testId: "salt-step-icon", + css: stepIconCSS, + window: targetWindow, + }); + + const ariaLabel = `${status || stage}: `; + + return ( +
+ +
+ ); +} + +export function useStepIcon({ + stage, + status, +}: Pick) { + const { + ErrorIcon, + WarningIcon, + ActiveIcon, + CompletedIcon, + PendingIcon, + InProgressIcon, + LockedIcon, + } = useIcon(); + + const stepIconMap = { + error: ErrorIcon, + warning: WarningIcon, + active: ActiveIcon, + completed: CompletedIcon, + pending: PendingIcon, + inprogress: InProgressIcon, + locked: LockedIcon, + }; + + return stepIconMap[status || stage]; +} diff --git a/packages/lab/src/stepper/Step.Label.css b/packages/lab/src/stepper/Step.Label.css new file mode 100644 index 00000000000..cacbea5791c --- /dev/null +++ b/packages/lab/src/stepper/Step.Label.css @@ -0,0 +1,17 @@ +.saltStepLabel { + grid-area: label; +} + +.saltStepper-horizontal .saltStepLabel { + margin-top: var(--salt-spacing-50); +} + +.saltStepper-vertical .saltStepLabel { + padding-top: calc((var(--salt-size-base) - var(--salt-text-label-lineHeight)) / 2); + padding-bottom: calc((var(--salt-size-base) - var(--salt-text-label-lineHeight)) / 2); + padding-left: calc((var(--saltStep-depth)) * var(--salt-spacing-100)); +} + +.saltStep-depth-0 > .saltStepLabel { + font-weight: var(--salt-text-fontWeight-strong) !important; +} diff --git a/packages/lab/src/stepper/Step.Label.tsx b/packages/lab/src/stepper/Step.Label.tsx new file mode 100644 index 00000000000..e88a7127391 --- /dev/null +++ b/packages/lab/src/stepper/Step.Label.tsx @@ -0,0 +1,38 @@ +import { Text, type TextProps, makePrefixer } from "@salt-ds/core"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import clsx from "clsx"; + +import stepLabelCSS from "./Step.Label.css"; + +export namespace StepLabel { + export interface Props extends TextProps<"div"> { + id: string; + } +} + +const withBaseName = makePrefixer("saltStepLabel"); + +export function StepLabel({ + id, + className, + styleAs = "label", + ...props +}: StepLabel.Props) { + const targetWindow = useWindow(); + + useComponentCssInjection({ + testId: "salt-step-label", + css: stepLabelCSS, + window: targetWindow, + }); + + return ( + + ); +} diff --git a/packages/lab/src/stepper/Step.css b/packages/lab/src/stepper/Step.css new file mode 100644 index 00000000000..f8ac4036fe2 --- /dev/null +++ b/packages/lab/src/stepper/Step.css @@ -0,0 +1,64 @@ +.saltStep { + /* Copy of size calculations of */ + --icon-base-size: var(--salt-size-icon, 12px); + --icon-size-multiplier: var(--saltIcon-size-multiplier, 1.5); + --icon-size: calc(var(--icon-base-size) * var(--icon-size-multiplier)); +} + +.saltStepper-horizontal .saltStep { + position: relative; + display: grid; + grid-template-columns: 1fr; + grid-template-rows: repeat(3, min-content); + grid-template-areas: + "icon" + "label" + "description"; + justify-items: center; + align-items: center; + text-align: center; + padding: 0 var(--salt-spacing-25); +} + +.saltStepper-vertical .saltStep { + display: grid; + grid-template-columns: var(--icon-size) 1fr min-content; + grid-template-areas: + "icon label expand" + "connector description ." + "stepper stepper stepper"; + justify-items: start; + align-items: start; + gap: 0 var(--salt-spacing-100); + width: 100%; + transition-duration: inherit; + transition-timing-function: inherit; + transition-property: grid-template-rows; +} + +@media (prefers-reduced-motion) { + .saltStepper-vertical .saltStep { + transition-duration: var(--salt-duration-instant); + } +} + +.saltStepper-vertical .saltStep-terminal { + grid-template-areas: + "icon label label" + "connector description description" + "stepper stepper stepper"; +} + +.saltStepper-vertical > .saltStep.saltStep-expanded { + grid-template-rows: + var(--salt-size-base) + min-content + 1fr; +} + +.saltStepper-vertical > .saltStep.saltStep-collapsed { + grid-template-rows: + var(--salt-size-base) + min-content + 0fr; +} diff --git a/packages/lab/src/stepper/Step.tsx b/packages/lab/src/stepper/Step.tsx new file mode 100644 index 00000000000..ca04e12a8b7 --- /dev/null +++ b/packages/lab/src/stepper/Step.tsx @@ -0,0 +1,156 @@ +import { + type ButtonProps, + makePrefixer, + useControlled, + useId, +} from "@salt-ds/core"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import clsx from "clsx"; +import { + type CSSProperties, + type ComponentProps, + type ReactNode, + useContext, + useEffect, +} from "react"; + +import { StepConnector } from "./Step.Connector"; +import { StepDescription } from "./Step.Description"; +import { StepExpandTrigger } from "./Step.ExpandTrigger"; +import { StepIcon } from "./Step.Icon"; +import { StepLabel } from "./Step.Label"; +import stepCSS from "./Step.css"; +import { Stepper } from "./Stepper"; +import { DepthContext } from "./Stepper.Provider"; + +export namespace Step { + export interface Props extends ComponentProps<"li"> { + id?: string; + label?: ReactNode; + description?: ReactNode; + status?: Step.Status; + stage?: Step.Stage; + expanded?: boolean; + defaultExpanded?: boolean; + onToggle?: ButtonProps["onClick"]; + substeps?: Step.Props[]; + children?: ReactNode; + } + + export type Status = "warning" | "error"; + + export type Stage = + | "pending" + | "locked" + | "completed" + | "inprogress" + | "active"; + + export type Depth = number; +} + +const withBaseName = makePrefixer("saltStep"); + +export function Step({ + id: idProp, + label, + description, + status, + stage = "pending", + expanded: expandedProp, + defaultExpanded, + onToggle, + className, + style, + substeps, + children, + ...props +}: Step.Props) { + const id = useId(idProp); + const targetWindow = useWindow(); + const depth = useContext(DepthContext); + + const [expanded, setExpanded] = useControlled({ + controlled: expandedProp, + default: Boolean(defaultExpanded), + + name: "Step", + state: "expanded", + }); + + useComponentCssInjection({ + testId: "salt-step", + css: stepCSS, + window: targetWindow, + }); + + useEffect(() => { + if (depth === -1) { + console.warn(" should be used within a component!"); + } + + if (depth > 2) { + console.warn(" should not be nested more than 2 levels deep!"); + } + }, [depth]); + + const iconMultiplier = depth === 0 ? 1.5 : 1; + const hasNestedSteps = !!children || !!substeps; + const labelId = `step-${id}-label`; + const descriptionId = `step-${id}-description`; + const nestedStepperId = `step-${id}-nested-stepper`; + + return ( +
  • + + + {label && {label}} + {description && {description}} + {hasNestedSteps && ( + { + onToggle?.(event); + setExpanded(!expanded); + }} + /> + )} + {hasNestedSteps && ( + + )} +
  • + ); +} diff --git a/packages/lab/src/stepper/Stepper.Provider.tsx b/packages/lab/src/stepper/Stepper.Provider.tsx new file mode 100644 index 00000000000..7c1dc4a006c --- /dev/null +++ b/packages/lab/src/stepper/Stepper.Provider.tsx @@ -0,0 +1,30 @@ +import { type ReactNode, createContext, useContext } from "react"; + +import type { Step } from "./Step"; +import type { Stepper } from "./Stepper"; + +export const DepthContext = createContext(-1); +export const OrientationContext = + createContext("horizontal"); + +export namespace StepperProvider { + export interface Props { + orientation: Stepper.Orientation; + children: ReactNode; + } +} + +export function StepperProvider({ + orientation: orientationProp, + children, +}: StepperProvider.Props) { + const depth = useContext(DepthContext); + + return ( + + + {children} + + + ); +} diff --git a/packages/lab/src/stepper/Stepper.css b/packages/lab/src/stepper/Stepper.css new file mode 100644 index 00000000000..07e76edc4d4 --- /dev/null +++ b/packages/lab/src/stepper/Stepper.css @@ -0,0 +1,35 @@ +.saltStepper { + grid-area: stepper; + width: 100%; + height: 100%; + + padding: 2px; + margin: -2px; + list-style-type: none; + box-sizing: content-box; + + transition-duration: var(--salt-duration-perceptible); + transition-timing-function: ease-in-out; + transition-property: opacity, visibility; +} + +.saltStepper-horizontal { + display: flex; + flex-direction: row; +} + +.saltStepper-vertical { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.saltStepper-vertical > .saltStep.saltStep-expanded > .saltStepper { + opacity: 1; + visibility: visible; +} + +.saltStepper-vertical > .saltStep.saltStep-collapsed > .saltStepper { + opacity: 0; + visibility: hidden; +} diff --git a/packages/lab/src/stepper/Stepper.tsx b/packages/lab/src/stepper/Stepper.tsx new file mode 100644 index 00000000000..fb36a04b57b --- /dev/null +++ b/packages/lab/src/stepper/Stepper.tsx @@ -0,0 +1,53 @@ +import { makePrefixer } from "@salt-ds/core"; +import { useComponentCssInjection } from "@salt-ds/styles"; +import { useWindow } from "@salt-ds/window"; +import { clsx } from "clsx"; +import { + type ComponentProps, + type ReactNode, + forwardRef, + useContext, +} from "react"; + +import { OrientationContext, StepperProvider } from "./Stepper.Provider"; +import stepperCSS from "./Stepper.css"; + +export namespace Stepper { + export interface Props extends ComponentProps<"ol"> { + orientation?: Orientation; + children: ReactNode; + } + + export type Orientation = "horizontal" | "vertical"; +} + +const withBaseName = makePrefixer("saltStepper"); + +export const Stepper = forwardRef( + function Stepper( + { orientation: orientationProp, children, className, ...props }, + ref, + ) { + const targetWindow = useWindow(); + const orientationContext = useContext(OrientationContext); + const orientation = orientationProp || orientationContext; + + useComponentCssInjection({ + testId: "salt-stepper", + css: stepperCSS, + window: targetWindow, + }); + + return ( + +
      + {children} +
    +
    + ); + }, +); diff --git a/packages/lab/src/stepper/index.ts b/packages/lab/src/stepper/index.ts new file mode 100644 index 00000000000..6531ba057f9 --- /dev/null +++ b/packages/lab/src/stepper/index.ts @@ -0,0 +1,17 @@ +import type { Step } from "./Step"; +import type { Stepper } from "./Stepper"; +import type { SteppedReducer } from "./useSteppedReducer"; + +export * from "./Stepper"; +export interface StepperProps extends Stepper.Props {} +export type StepperOrientation = Stepper.Orientation; + +export * from "./Step"; +export interface StepProps extends Step.Props {} +export type StepStatus = Step.Status; +export type StepStage = Step.Stage; +export type StepDepth = Step.Depth; + +export * from "./useSteppedReducer"; +export type SteppedReducerState = SteppedReducer.State; +export type SteppedReducerAction = SteppedReducer.Action; diff --git a/packages/lab/src/stepper/useSteppedReducer.ts b/packages/lab/src/stepper/useSteppedReducer.ts new file mode 100644 index 00000000000..596042343fa --- /dev/null +++ b/packages/lab/src/stepper/useSteppedReducer.ts @@ -0,0 +1,161 @@ +import { useReducer } from "react"; + +import type { Step } from "./Step"; +import { assignStage, autoStage, flatten } from "./utils"; + +export namespace SteppedReducer { + export interface State { + steps: Step.Props[]; + flatSteps: Step.Props[]; + activeStep: Step.Props | null; + previousStep: Step.Props | null; + nextStep: Step.Props | null; + activeStepIndex: number; + started: boolean; + ended: boolean; + } + + export type Action = + | { type: "next" } + | { type: "previous" } + | { type: "error" } + | { type: "warning" } + | { type: "clear" }; +} + +function steppedReducer( + state: SteppedReducer.State, + action: SteppedReducer.Action, +) { + if (action.type === "next") { + const steps = assignStage(state.steps); + const flatSteps = flatten(steps); + + if (state.nextStep) { + const activeStepIndex = state.activeStepIndex + 1; + const activeStep = flatSteps[activeStepIndex]; + const previousStep = flatSteps[activeStepIndex - 1] || null; + const nextStep = flatSteps[activeStepIndex + 1] || null; + + if (activeStep) { + activeStep.stage = "active"; + } + + return { + steps: autoStage(steps), + flatSteps, + activeStepIndex, + activeStep, + previousStep, + nextStep, + started: true, + ended: false, + }; + } + + const activeStepIndex = flatSteps.length; + const previousStep = flatSteps.at(-1); + const activeStep = null; + const nextStep = null; + + return { + steps: assignStage(steps, "completed"), + flatSteps, + activeStepIndex, + activeStep, + previousStep, + nextStep, + started: true, + ended: true, + } as SteppedReducer.State; + } + + if (action.type === "previous") { + const steps = assignStage(state.steps); + const flatSteps = flatten(steps); + + if (state.previousStep) { + const activeStepIndex = state.activeStepIndex - 1; + const activeStep = flatSteps[activeStepIndex]; + const previousStep = flatSteps[activeStepIndex - 1] || null; + const nextStep = flatSteps[activeStepIndex + 1] || null; + + if (activeStep) { + activeStep.stage = "active"; + } + + return { + steps: autoStage(steps), + flatSteps, + activeStepIndex, + activeStep, + previousStep, + nextStep, + started: true, + ended: false, + } as SteppedReducer.State; + } + + const activeStepIndex = -1; + const activeStep = null; + const previousStep = null; + const nextStep = flatSteps.at(0); + + return { + steps: assignStage(steps, "pending"), + flatSteps, + activeStepIndex, + activeStep, + previousStep, + nextStep, + ended: false, + started: false, + } as SteppedReducer.State; + } + + if (action.type === "error") { + if (state.activeStep) { + state.activeStep.status = "error"; + } + } + + if (action.type === "warning") { + if (state.activeStep) { + state.activeStep.status = "warning"; + } + } + + if (action.type === "clear") { + if (state.activeStep) { + state.activeStep.status = undefined; + } + } + + return { ...state }; +} + +export function useSteppedReducer(initialSteps: Step.Props[]) { + const steps = autoStage(initialSteps); + const flatSteps = flatten(steps); + const activeStepIndex = flatSteps.findIndex( + (step) => step.stage === "active", + ); + const activeStep = flatSteps[activeStepIndex]; + const previousStep = flatSteps[activeStepIndex - 1] || null; + const nextStep = flatSteps[activeStepIndex + 1] || null; + const started = !flatSteps.every((step) => step.stage === "pending"); + const ended = flatSteps.every((step) => step.stage === "completed"); + + const state: SteppedReducer.State = { + steps, + flatSteps, + activeStep, + previousStep, + nextStep, + activeStepIndex, + ended, + started, + }; + + return useReducer(steppedReducer, state); +} diff --git a/packages/lab/src/stepper/utils.spec.tsx b/packages/lab/src/stepper/utils.spec.tsx new file mode 100644 index 00000000000..4034163d118 --- /dev/null +++ b/packages/lab/src/stepper/utils.spec.tsx @@ -0,0 +1,323 @@ +import { describe, expect, it } from "vitest"; + +import { assignStage, autoStage, flatten } from "./utils"; + +import type { Step } from "./Step"; + +describe("Stepper > utils.ts", () => { + describe("assignStage", () => { + it("should assign an array of steps to a stage (completed)", () => { + const steps: Step.Props[] = [{ id: "1" }, { id: "2" }, { id: "3" }]; + + const result = assignStage(steps, "completed"); + + expect(result).toEqual([ + { id: "1", stage: "completed" }, + { id: "2", stage: "completed" }, + { id: "3", stage: "completed" }, + ]); + }); + it("should assign an array of steps to a stage (pending)", () => { + const steps: Step.Props[] = [{ id: "1" }, { id: "2" }, { id: "3" }]; + + const result = assignStage(steps, "pending"); + + expect(result).toEqual([ + { id: "1", stage: "pending" }, + { id: "2", stage: "pending" }, + { id: "3", stage: "pending" }, + ]); + }); + it("should assign an array of nested steps to a stage", () => { + const steps: Step.Props[] = [ + { id: "1" }, + { + id: "2", + substeps: [{ id: "2.1" }, { id: "2.2" }], + }, + { + id: "3", + substeps: [ + { + id: "3.1", + substeps: [{ id: "3.1.1" }], + }, + ], + }, + ]; + + const result = assignStage(steps, "completed"); + + expect(result).toEqual([ + { id: "1", stage: "completed" }, + { + id: "2", + stage: "completed", + substeps: [ + { id: "2.1", stage: "completed" }, + { id: "2.2", stage: "completed" }, + ], + }, + { + id: "3", + stage: "completed", + substeps: [ + { + id: "3.1", + stage: "completed", + substeps: [{ id: "3.1.1", stage: "completed" }], + }, + ], + }, + ]); + }); + }); + describe("autoStage", () => { + it("should return a fresh instance of the object config", () => { + const config: Step.Props[] = [{ id: "1" }, { id: "2" }, { id: "3" }]; + + expect(autoStage(config)).not.toBe(config); + expect(autoStage(config)).toEqual([ + { id: "1", stage: "pending" }, + { id: "2", stage: "pending" }, + { id: "3", stage: "pending" }, + ]); + }); + it("should set steps before active step to completed", () => { + const config: Step.Props[] = [ + { id: "1" }, + { id: "2" }, + { id: "3", stage: "active" }, + ]; + + const result = autoStage(config); + + expect(result[0]).toHaveProperty("stage", "completed"); + expect(result[1]).toHaveProperty("stage", "completed"); + expect(result[2]).toHaveProperty("stage", "active"); + }); + it("should set steps after active step to pending", () => { + const config: Step.Props[] = [ + { id: "1", stage: "active" }, + { id: "2" }, + { id: "3" }, + ]; + + const result = autoStage(config); + + expect(result[0]).toHaveProperty("stage", "active"); + expect(result[1]).toHaveProperty("stage", "pending"); + expect(result[2]).toHaveProperty("stage", "pending"); + }); + it("should set steps with active substeps to inprogress on top step", () => { + const config: Step.Props[] = [ + { + id: "1", + substeps: [ + { id: "1.1" }, + { id: "1.2", stage: "active" }, + { id: "1.3" }, + ], + }, + { id: "2" }, + { id: "3" }, + ]; + + const result = autoStage(config); + + const expected: Step.Props[] = [ + { + id: "1", + stage: "inprogress", + substeps: [ + { id: "1.1", stage: "completed" }, + { id: "1.2", stage: "active" }, + { id: "1.3", stage: "pending" }, + ], + }, + { id: "2", stage: "pending" }, + { id: "3", stage: "pending" }, + ]; + + expect(result).toEqual(expected); + }); + it("should set steps with active substeps to inprogress on middle step", () => { + const config: Step.Props[] = [ + { id: "1" }, + { + id: "2", + substeps: [ + { id: "2.1" }, + { id: "2.2" }, + { id: "2.3", stage: "active" }, + ], + }, + { id: "3" }, + ]; + + const result = autoStage(config); + + const expected: Step.Props[] = [ + { id: "1", stage: "completed" }, + { + id: "2", + stage: "inprogress", + substeps: [ + { id: "2.1", stage: "completed" }, + { id: "2.2", stage: "completed" }, + { id: "2.3", stage: "active" }, + ], + }, + { id: "3", stage: "pending" }, + ]; + + expect(result).toEqual(expected); + }); + it("should set steps with active substeps to inprogress on middle step with substeps above", () => { + const config: Step.Props[] = [ + { + id: "1", + substeps: [{ id: "1.1" }, { id: "1.2" }, { id: "1.3" }], + }, + { + id: "2", + substeps: [ + { id: "2.1" }, + { id: "2.2", stage: "active" }, + { id: "2.3" }, + ], + }, + { id: "3" }, + ]; + + const expected: Step.Props[] = [ + { + id: "1", + stage: "completed", + substeps: [ + { id: "1.1", stage: "completed" }, + { id: "1.2", stage: "completed" }, + { id: "1.3", stage: "completed" }, + ], + }, + { + id: "2", + stage: "inprogress", + substeps: [ + { id: "2.1", stage: "completed" }, + { id: "2.2", stage: "active" }, + { id: "2.3", stage: "pending" }, + ], + }, + { id: "3", stage: "pending" }, + ]; + + expect(autoStage(config)).toEqual(expected); + }); + it("should set steps with active substeps to inprogress on middle step with substeps above", () => { + const config: Step.Props[] = [ + { + id: "1", + substeps: [{ id: "1.1" }, { id: "1.2" }, { id: "1.3" }], + }, + { + id: "2", + substeps: [ + { id: "2.1" }, + { + id: "2.2", + substeps: [ + { id: "2.2.1" }, + { id: "2.2.2", stage: "active" }, + { id: "2.2.3" }, + ], + }, + { id: "2.3" }, + ], + }, + { id: "3" }, + ]; + + const expected: Step.Props[] = [ + { + id: "1", + stage: "completed", + substeps: [ + { id: "1.1", stage: "completed" }, + { id: "1.2", stage: "completed" }, + { id: "1.3", stage: "completed" }, + ], + }, + { + id: "2", + stage: "inprogress", + substeps: [ + { id: "2.1", stage: "completed" }, + { + id: "2.2", + stage: "inprogress", + substeps: [ + { id: "2.2.1", stage: "completed" }, + { id: "2.2.2", stage: "active" }, + { id: "2.2.3", stage: "pending" }, + ], + }, + { id: "2.3", stage: "pending" }, + ], + }, + { id: "3", stage: "pending" }, + ]; + + expect(autoStage(config)).toEqual(expected); + }); + }); + + describe("flatten", () => { + it("should return a the same array if no substeps", () => { + const steps: Step.Props[] = [{ id: "1" }, { id: "2" }, { id: "3" }]; + + expect(flatten(steps)).toEqual(steps); + }); + it("should return flatten array of steps (depth 1)", () => { + const steps: Step.Props[] = [ + { id: "1" }, + { + id: "2", + substeps: [{ id: "2.1" }, { id: "2.2", stage: "active" }], + }, + { id: "3" }, + ]; + + expect(flatten(steps)).toEqual([ + { id: "1" }, + { id: "2.1" }, + { id: "2.2", stage: "active" }, + { id: "3" }, + ]); + }); + it("should return flatten array of steps (depth 2)", () => { + const steps: Step.Props[] = [ + { id: "1" }, + { + id: "2", + substeps: [ + { id: "2.1" }, + { + id: "2.2", + substeps: [{ id: "2.2.1" }, { id: "2.2.2", stage: "active" }], + }, + ], + }, + { id: "3" }, + ]; + + expect(flatten(steps)).toEqual([ + { id: "1" }, + { id: "2.1" }, + { id: "2.2.1" }, + { id: "2.2.2", stage: "active" }, + { id: "3" }, + ]); + }); + }); +}); diff --git a/packages/lab/src/stepper/utils.ts b/packages/lab/src/stepper/utils.ts new file mode 100644 index 00000000000..cc3c403453a --- /dev/null +++ b/packages/lab/src/stepper/utils.ts @@ -0,0 +1,68 @@ +import type { Step } from "./Step"; + +export function assignStage( + steps: Step.Props[], + stage?: Step.Stage, +): Step.Props[] { + return steps.map((step) => { + step.stage = stage; + if (step.substeps) { + step.substeps = assignStage(step.substeps, stage); + } + + return step; + }); +} + +export function autoStage(steps: Step.Props[]): Step.Props[] { + function autoStageHelper(steps: Step.Props[]): Step.Props[] | null { + const pivotIndex = steps.findIndex( + (step) => step.stage === "active" || step.stage === "inprogress", + ); + + if (pivotIndex !== -1) { + const activeStep = steps[pivotIndex] as Step.Props; + const previousSteps = assignStage( + steps.slice(0, pivotIndex), + "completed", + ); + const nextSteps = assignStage(steps.slice(pivotIndex + 1), "pending"); + + return [...previousSteps, activeStep, ...nextSteps] as Step.Props[]; + } + + return steps.reduce( + (acc, step, index) => { + if (step.substeps) { + const substeps = autoStageHelper(step.substeps); + + if (substeps) { + steps[index].substeps = substeps; + steps[index].stage = "inprogress"; + + return autoStageHelper(steps); + } + } + + return acc; + }, + null as Step.Props[] | null, + ); + } + + return autoStageHelper(steps) || assignStage(steps, "pending"); +} + +export function flatten(steps: Step.Props[]): Step.Props[] { + return steps.reduce((acc, step) => { + if (step.substeps) { + acc.push(...flatten(step.substeps)); + + return acc; + } + + acc.push(step); + + return acc; + }, [] as Step.Props[]); +} diff --git a/packages/lab/stories/stepper/stepper.qa.stories.tsx b/packages/lab/stories/stepper/stepper.qa.stories.tsx new file mode 100644 index 00000000000..d04a4cff737 --- /dev/null +++ b/packages/lab/stories/stepper/stepper.qa.stories.tsx @@ -0,0 +1,222 @@ +import { StackLayout } from "@salt-ds/core"; +import { Step, Stepper } from "@salt-ds/lab"; +import { QAContainer, type QAContainerProps } from "docs/components"; + +import type { Meta, StoryFn } from "@storybook/react"; + +export default { + title: "Lab/Stepper/Stepper QA", + component: Stepper, + subcomponents: { Step }, +} as Meta; + +export const Horizontal: StoryFn = (props) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +Horizontal.parameters = { + chromatic: { disableSnapshot: false }, +}; + +export const HorizontalVariations: StoryFn = (props) => { + return ( + + + + + + + + + + + + + + ); +}; + +HorizontalVariations.parameters = { + chromatic: { disableSnapshot: false }, +}; + +export const Vertical: StoryFn = (props) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +Vertical.parameters = { + chromatic: { disableSnapshot: false }, +}; + +export const VerticalVariations: StoryFn = (props) => { + return ( + + + + + + + + + + + + + + ); +}; + +VerticalVariations.parameters = { + chromatic: { disableSnapshot: false }, +}; + +export const VerticalNesting: StoryFn = (props) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/lab/stories/stepper/stepper.stories.tsx b/packages/lab/stories/stepper/stepper.stories.tsx new file mode 100644 index 00000000000..8e0a69252b4 --- /dev/null +++ b/packages/lab/stories/stepper/stepper.stories.tsx @@ -0,0 +1,339 @@ +import { + Button, + FlexLayout, + SegmentedButtonGroup, + StackLayout, +} from "@salt-ds/core"; +import { Step, Stepper, useSteppedReducer } from "@salt-ds/lab"; +import type { Meta, StoryFn } from "@storybook/react"; + +export default { + title: "Lab/Stepper", + component: Stepper, + subcomponents: { Step }, +} as Meta; + +export const Horizontal: StoryFn = () => { + return ( + + + + + + + + ); +}; + +export const HorizontalVariations: StoryFn = () => { + return ( + + + + + + + + + + + + ); +}; + +export const HorizontalLongText: StoryFn = () => { + return ( + + + + + + + + ); +}; + +export const HorizontalInteractiveUsingSteppedReducer: StoryFn< + typeof Stepper +> = () => { + const [state, dispatch] = useSteppedReducer([ + { id: "step-1", label: "Step 1" }, + { id: "step-2", label: "Step 2", stage: "active" }, + { id: "step-3", label: "Step 3" }, + ]); + + return ( + + + {state.steps.map((step) => ( + + ))} + + + {state.started && ( + + )} + {!state.ended && ( + + )} + + + ); +}; + +export const Vertical: StoryFn = () => { + return ( + + + + + + + + ); +}; + +export const VerticalVariations: StoryFn = () => { + return ( + + + + + + + + + + + + ); +}; + +export const VerticalLongText: StoryFn = () => { + return ( + + + + + + + + ); +}; + +export const VerticalDepth1: StoryFn = () => { + return ( + + + + + + + + + + + + + + + + + + + + ); +}; + +export const VerticalDepth2: StoryFn = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export const VerticalInteractiveUsingSteppedReducer: StoryFn< + typeof Stepper +> = () => { + const [state, dispatch] = useSteppedReducer([ + { + id: "step-1", + label: "Step 1", + defaultExpanded: true, + substeps: [ + { id: "step-1-1", label: "Step 1.1" }, + { id: "step-1-2", label: "Step 1.2" }, + { + id: "step-1-3", + label: "Step 1.3", + defaultExpanded: true, + substeps: [ + { id: "step-1-3-1", label: "Step 1.3.1" }, + { id: "step-1-3-2", label: "Step 1.3.2" }, + { + id: "step-1-3-3", + label: "Step 1.3.3", + description: "This is just a description text", + }, + ], + }, + { id: "step-1-4", label: "Step 1.4" }, + ], + }, + { id: "step-2", label: "Step 2" }, + { id: "step-3", label: "Step 3" }, + ]); + + return ( + + + {state.steps.map((step) => ( + + ))} + + + {state.started && ( + + )} + {!state.ended && ( + + )} + + + {state.started && !state.ended && ( + <> + + + + + )} + + + ); +}; + +export const BareBones: StoryFn = () => { + return ( + + + + + + + + ); +}; diff --git a/site/docs/components/stepper/examples.mdx b/site/docs/components/stepper/examples.mdx new file mode 100644 index 00000000000..19db86b0490 --- /dev/null +++ b/site/docs/components/stepper/examples.mdx @@ -0,0 +1,71 @@ +--- + title: + $ref: ./#/title + layout: DetailComponent + sidebar: + exclude: true + data: + $ref: ./#/data +--- + + + + + ## Basic + +The `Stepper` component accepts `Step` components as children. +You can change + + + +## Orientation + + + ### Horizontal (default) + + + + ### Vertical + + + + ## Stage + Status + +The `Step` component changes based on the `stage` and `status` props. +In case they are both set, `status` will take precedence over `stage`. +These 2 props control various aspects of the component, such as: +icon selection, description text color and track (connector) style. + + + + + ## Nested Steps + +When using the `Stepper` component in vertical orientation, you can nest `Step` components other `Step` components. +When done so, the parent `Step` renders another `Stepper` and places the nested `Steps` (children) inside of it. +Additionally an expand button is added on the parent `Step` to expand or collapse the substeps. +The parent Step containing the active Step child children are to have a stage of `inprogress`. + + + + + ## Hook + +The `Stepper` and `Step` component are purely presentational components. +If you want to control the state of the `Stepper` you can utilize the `useSteppedReducer` hook. + + + + + The `useSteppedReducer` hook is smart! If you add `stage: 'active'` to any step, it + will automatically determine the stages of all other steps (autoStage), both + before and after the active one. Inside the state object, you can find useful + properties such as `steps`, `flatSteps`, `activeStep`, `previousStep`, + `nextStep`, `started` and `ended`. The dispatch method as in the native + useReducer hook accepts an action object with a type. Acceptable action types + are: 'next', 'previous', 'warning', 'error' and 'clear'. The warning and error + types will set the status of the active step to 'warning' or 'error'. The clear + type will reset the status of the active step to undefined. + + + diff --git a/site/docs/components/stepper/index.mdx b/site/docs/components/stepper/index.mdx new file mode 100644 index 00000000000..369996b2420 --- /dev/null +++ b/site/docs/components/stepper/index.mdx @@ -0,0 +1,28 @@ +--- +title: Stepper +data: + description: "`Stepper` visually communicates a user’s progress through a linear process. It gives the user context about where they are in the process, indicating the remaining steps and the state of all steps. It can communicate new information, errors, warnings or successful completion of a process or task." + sourceCodeUrl: "https://github.com/jpmorganchase/salt-ds/tree/main/packages/lab/src/stepper" + package: + name: "@salt-ds/lab" + alsoKnownAs: + [ + "Discrete progress indicator", + "Progress indicator", + "Progress stepper", + "Progress steps", + "Screen flow", + "Status tracker", + "Stepped tracker", + ] + relatedComponents: + [ + { name: "Accordion", relationship: "contains" }, + { name: "Icon", relationship: "contains" }, + ] + +# Leave this as is +layout: DetailComponent +--- + +{/* This area stays blank */} diff --git a/site/docs/components/stepper/usage.mdx b/site/docs/components/stepper/usage.mdx new file mode 100644 index 00000000000..c7728c0b9c2 --- /dev/null +++ b/site/docs/components/stepper/usage.mdx @@ -0,0 +1,47 @@ +--- +title: + $ref: ./#/title +layout: DetailComponent +sidebar: + exclude: true +data: + $ref: ./#/data +--- + +## Using the components + +### When to use + +- When there are separate steps within a process, such as a wizard or a multistep form. +- When you want to split a process into distinct, bite-sized sections that are less daunting than lengthy forms. + +### When not to use + +- When there are fewer than three steps in a process. +- As navigation; users cannot interact with the steps. + +## Content + +Step labels never truncate, only wrap. Therefore, ensure they're short and self-explanatory. + +## Import + +To import `Stepper` and related components from the lab Salt package, use: + +``` +import { Stepper, Step, useSteppedReducer } from "@salt-ds/lab"; +``` + +## Props + +### `Stepper` + + + +### `Step` + + + +### `useSteppedReducer` + + diff --git a/site/src/examples/stepper/Basic.tsx b/site/src/examples/stepper/Basic.tsx new file mode 100644 index 00000000000..efc436f815c --- /dev/null +++ b/site/src/examples/stepper/Basic.tsx @@ -0,0 +1,14 @@ +import { StackLayout } from "@salt-ds/core"; +import { Step, Stepper } from "@salt-ds/lab"; + +export const Basic = () => { + return ( + + + + + + + + ); +}; diff --git a/site/src/examples/stepper/Hook.tsx b/site/src/examples/stepper/Hook.tsx new file mode 100644 index 00000000000..3603805cd74 --- /dev/null +++ b/site/src/examples/stepper/Hook.tsx @@ -0,0 +1,27 @@ +import { Button, FlexLayout } from "@salt-ds/core"; +import { StackLayout } from "@salt-ds/core"; +import { Step, Stepper, useSteppedReducer } from "@salt-ds/lab"; + +const initialSteps = [ + { id: "step-1", label: "Step 1" }, + { id: "step-2", label: "Step 2" }, + { id: "step-3", label: "Step 3" }, +] as Step.Props[]; + +export function Hook() { + const [state, dispatch] = useSteppedReducer(initialSteps); + + return ( + + + {state.steps.map((step) => ( + + ))} + + + + + + + ); +} diff --git a/site/src/examples/stepper/HookAdvanced.tsx b/site/src/examples/stepper/HookAdvanced.tsx new file mode 100644 index 00000000000..f53bedb5e39 --- /dev/null +++ b/site/src/examples/stepper/HookAdvanced.tsx @@ -0,0 +1,96 @@ +import { Button, SegmentedButtonGroup, StackLayout } from "@salt-ds/core"; +import { Step, Stepper, useSteppedReducer } from "@salt-ds/lab"; + +const initialSteps = [ + { + id: "step-1", + label: "Step 1", + defaultExpanded: true, + substeps: [ + { id: "step-1-1", label: "Step 1.1" }, + { id: "step-1-2", label: "Step 1.2" }, + { + id: "step-1-3", + label: "Step 1.3", + defaultExpanded: true, + substeps: [ + { + id: "step-1-3-1", + label: "Step 1.3.1", + stage: "active", + }, + { id: "step-1-3-2", label: "Step 1.3.2" }, + { + id: "step-1-3-3", + label: "Step 1.3.3", + description: "This is just a description text", + }, + ], + }, + { id: "step-1-4", label: "Step 1.4" }, + ], + }, + { id: "step-2", label: "Step 2" }, + { id: "step-3", label: "Step 3" }, +] satisfies Step.Props[]; + +export const HookAdvanced = () => { + const [state, dispatch] = useSteppedReducer(initialSteps); + + return ( + + + {state.steps.map((step) => ( + + ))} + + + {state.started && ( + + )} + {!state.ended && ( + + )} + + + {state.started && !state.ended && ( + <> + + + + + )} + + + ); +}; diff --git a/site/src/examples/stepper/NestedSteps.tsx b/site/src/examples/stepper/NestedSteps.tsx new file mode 100644 index 00000000000..2ba341b84e2 --- /dev/null +++ b/site/src/examples/stepper/NestedSteps.tsx @@ -0,0 +1,31 @@ +import { StackLayout } from "@salt-ds/core"; +import { Step, Stepper } from "@salt-ds/lab"; + +export const NestedSteps = () => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/site/src/examples/stepper/OrientationHorizontal.tsx b/site/src/examples/stepper/OrientationHorizontal.tsx new file mode 100644 index 00000000000..991076e33f8 --- /dev/null +++ b/site/src/examples/stepper/OrientationHorizontal.tsx @@ -0,0 +1,14 @@ +import { StackLayout } from "@salt-ds/core"; +import { Step, Stepper } from "@salt-ds/lab"; + +export const OrientationHorizontal = () => { + return ( + + + + + + + + ); +}; diff --git a/site/src/examples/stepper/OrientationVertical.tsx b/site/src/examples/stepper/OrientationVertical.tsx new file mode 100644 index 00000000000..c3efb2997b3 --- /dev/null +++ b/site/src/examples/stepper/OrientationVertical.tsx @@ -0,0 +1,14 @@ +import { StackLayout } from "@salt-ds/core"; +import { Step, Stepper } from "@salt-ds/lab"; + +export const OrientationVertical = () => { + return ( + + + + + + + + ); +}; diff --git a/site/src/examples/stepper/StageStatus.tsx b/site/src/examples/stepper/StageStatus.tsx new file mode 100644 index 00000000000..c0c8d7c113c --- /dev/null +++ b/site/src/examples/stepper/StageStatus.tsx @@ -0,0 +1,18 @@ +import { StackLayout } from "@salt-ds/core"; +import { Step, Stepper } from "@salt-ds/lab"; + +export const StageStatus = () => { + return ( + + + + + + + + + + + + ); +}; diff --git a/site/src/examples/stepper/index.ts b/site/src/examples/stepper/index.ts new file mode 100644 index 00000000000..a7c31504369 --- /dev/null +++ b/site/src/examples/stepper/index.ts @@ -0,0 +1,7 @@ +export * from "./Basic"; +export * from "./OrientationHorizontal"; +export * from "./OrientationVertical"; +export * from "./StageStatus"; +export * from "./NestedSteps"; +export * from "./Hook"; +export * from "./HookAdvanced";