Skip to content

Commit

Permalink
feat(stepper): Use stepper in controlled or uncontrolled mode (#2474)
Browse files Browse the repository at this point in the history
* feat(stepper): Use stepper in controlled or uncontrolled mode

* chore: Update changeset

* fix(stepper): fix some linting from external, exporting type stepKeys and StepKey

---------

Co-authored-by: Øyvind Eikeland <[email protected]>
  • Loading branch information
Andreas-Tonnessen and eikeland authored Feb 24, 2025
1 parent 14e365d commit eba9cf6
Show file tree
Hide file tree
Showing 6 changed files with 176 additions and 40 deletions.
61 changes: 61 additions & 0 deletions .changeset/academy-unrest-linear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
'@equinor/fusion-react-stepper': major
'@equinor/fusion-react-components-stories': patch
---

Changed implementation of the Stepper component from being uncontrolled to having a both an uncontrolled and controlled mode.

- Required 'activeStepKey' prop removed. Replaced with 'initialStepKey' and 'stepKey' (both optional)
- If 'stepKey' is set, then the component becomes controlled by that prop.
- If 'stepKey' is set and 'initialStepKey' is set, then the initial step will be that prop, but otherwise be uncontrolled.
- If none of these props are set then it will be uncontrolled, and default to the first step of the steps provided as the initial step.
- Updated 'Stepper' README and Storybook with examples of usage.

This is a breaking change since the 'activeStepKey' was required, and now needs to be either removed or replaced with one of the two new optional props.

## Migration guide:

### Uncontrolled usage:
```tsx
import { Stepper } from '@equinor/fusion-react-stepper';

/** initialStepKey here is optional. If not set it defaults to the first step ('step1') */
<Stepper initialStepKey="step1" onChange={(e, k) => console.log('active: ', e, ' keys: ', k)} props>
<Step title="Title 1" stepKey="step1" props>
Step content 1
</Step>
<Step title="Title 2" stepKey="step2" props>
Step content 2
</Step>
<Step title="Title 3" stepKey="step3" props>
Step content 3
</Step>
</Stepper>
```

### Controlled usage:
```tsx
import { Stepper } from '@equinor/fusion-react-stepper';

const [activeStep, setActiveStep] = useState<string>('step1');
const onChangeStep = (stepKey: string, allSteps: StepKey[]) => {
console.log('active: ', stepKey, ' keys: ', allSteps)
if (activeStep !== stepKey) {
setActiveStep(String(stepKey));
}
};

return (
<Stepper stepKey={activeStep} onChange={onChangeStep} props>
<Step title="Title 1" stepKey="step1" props>
Step content 1
</Step>
<Step title="Title 2" stepKey="step2" props>
Step content 2
</Step>
<Step title="Title 3" stepKey="step3" props>
Step content 3
</Step>
</Stepper>
);
```
54 changes: 41 additions & 13 deletions packages/stepper/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!--prettier-ignore-start-->
# @equinor/fusion-react-stepper
# @equinor/fusion-react-stepper

[![Published on npm](https://img.shields.io/npm/v/@equinor/fusion-react-stepper.svg)](https://www.npmjs.com/package/@equinor/fusion-react-stepper)

Expand All @@ -9,30 +9,32 @@

### for stepper

Name | Type | Default | Description
------------------------- | ----------------------------- | ----------------------------- | -----------
`activeStepKey` | `string` | `/` | Select/change active stepp, use *** stepKey ***. `required`
`forceOrder` | `boolean` | `false` | Can't skip steps. Steps will have specific order.
`verticalSteps` | `boolean` | `false` | Change stepper layout to vertical. Vertical positioning of steps.
`hideNavButtons` | `boolean` | `false` | Show/hide next and previous navigation buttons for stepper.
`onChange` | `(stepKey: string) => void` | | onChange event for active step.
Name | Type | Default | Description
------------------------- |-------------------------------| ----------------------------- | -----------
`initialStepKey` | `string / undefined` | `/` | Sets the initial step key for an uncontrolled stepper component. If undefined the stepper will use the first step as initial step.
`stepKey` | `string / undefined` | `/` | Used for making the stepper a controlled component. If it is set then it is the property that decides the currentStep and initialStepKey is ignored.
`forceOrder` | `boolean` | `false` | Can't skip steps. Steps will have specific order.
`verticalSteps` | `boolean` | `false` | Change stepper layout to vertical. Vertical positioning of steps.
`hideNavButtons` | `boolean` | `false` | Show/hide next and previous navigation buttons for stepper.
`onChange` | `(stepKey: string, allSteps: StepKey[]) => void` | | onChange event for active step.

### for step

Name | Type | Default | Description
------------------------- | ----------------------------- | ----------------------------- | -----------
`stepKey` | `string` | `/` | Step key of step. Used for *** activeStepKey ***. `required`
`stepKey` | `string` | `/` | Step key of step. Used for ***activeStepKey***. `required`
`title` | `string` | `/` | Title of step
`description` | `string` | `/` | Description of step
`description` | `string` | `/` | Description of step
`disabled` | `boolean` | `false` | Disable step. Can't be clicked, but can manually navigate to it


## Example Usage

### Uncontrolled usage

```tsx
import { Stepper } from '@equinor/fusion-react-stepper';

<Stepper activeStepKey="step1" onChange={(e) => console.log('active: ', e)} props>
<Stepper initialStepKey="step1" onChange={(e, k) => console.log('active: ', e, ' keys: ', k)} props>
<Step title="Title 1" stepKey="step1" props>
Step content 1
</Step>
Expand All @@ -45,4 +47,30 @@ import { Stepper } from '@equinor/fusion-react-stepper';
</Stepper>
```

<!--prettier-ignore-end-->
### Controlled usage

```tsx
import { Stepper } from '@equinor/fusion-react-stepper';

const [activeStep, setActiveStep] = useState<string>('step1');
const onChangeStep = (stepKey: string, allSteps: StepKey[]) => {
console.log('active: ', stepKey, ' keys: ', allSteps)
if (activeStep !== stepKey) {
setActiveStep(String(stepKey));
}
};

return (
<Stepper stepKey={activeStep} onChange={onChangeStep} props>
<Step title="Title 1" stepKey="step1" props>
Step content 1
</Step>
<Step title="Title 2" stepKey="step2" props>
Step content 2
</Step>
<Step title="Title 3" stepKey="step3" props>
Step content 3
</Step>
</Stepper>
);
```
39 changes: 28 additions & 11 deletions packages/stepper/src/Stepper.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { useState, useEffect, useCallback, PropsWithChildren, createContext, useContext } from 'react';
import {
useState,
useEffect,
useCallback,
PropsWithChildren,
createContext,
useContext,
Children,
ReactElement,
} from 'react';
import { findNextAvailable, findPrevAvailable, getSteps } from './utils';
import StepperContent from './StepperContent';

/** Define the props interface for Stepper component */
export type StepperProps = {
readonly onChange?: (stepKey: string, allSteps: StepKey[]) => void;
readonly forceOrder?: boolean;
readonly activeStepKey: string;
readonly initialStepKey?: string;
readonly stepKey?: string;
readonly hideNavButtons?: boolean;
readonly verticalSteps?: boolean;
readonly horizontalTitle?: boolean;
Expand All @@ -21,6 +31,8 @@ export type StepKey = {
done: boolean;
};

export type StepKeys = StepKey[];

/** Create context for Stepper */
type StepperContextType = {
verticalSteps?: boolean;
Expand All @@ -46,7 +58,8 @@ export const useStepperContext = () => {

export const Stepper = ({
children,
activeStepKey,
initialStepKey,
stepKey,
forceOrder,
onChange,
hideNavButtons,
Expand All @@ -56,7 +69,10 @@ export const Stepper = ({
}: PropsWithChildren<StepperProps>): JSX.Element => {
/** State to manage step keys, current step key, and active step position */
const [stepKeys, setStepKeys] = useState<StepKey[]>([]);
const [currentStepKey, setCurrentStepKey] = useState<string>(activeStepKey);
/** Fallback to key of first step if stepKey/initialStepKey is not set */
const [currentStepKey, setCurrentStepKey] = useState<string>(
stepKey ?? initialStepKey ?? (Children.toArray(children)[0] as ReactElement).props.stepKey,
);
const [activeStepPosition, setActiveStepPosition] = useState<number>(0);

/** State to manage navigation button availability */
Expand All @@ -69,10 +85,10 @@ export const Stepper = ({
setStepKeys(steps);
}, [children]);

/** Effect to update currentStepKey when activeStepKey changes */
/** Effect to update currentStepKey when stepKey changes */
useEffect(() => {
setCurrentStepKey(activeStepKey);
}, [activeStepKey]);
stepKey && setCurrentStepKey(stepKey);
}, [stepKey]);

/** Effect to update activeStepPosition, canNext, and canPrev when stepKeys or currentStepKey change */
useEffect(() => {
Expand All @@ -90,11 +106,12 @@ export const Stepper = ({

/** Callback to handle step change */
const handleChange = useCallback(
(stepKey: string, allSteps: StepKey[]) => {
setCurrentStepKey(stepKey);
onChange && onChange(stepKey, allSteps);
(newStepKey: string, allSteps: StepKey[]) => {
/** If stepKey is undefined we call setCurrentStepKey here since it is then an uncontrolled component */
!stepKey && setCurrentStepKey(newStepKey);
onChange && onChange(newStepKey, allSteps);
},
[onChange],
[onChange, stepKey],
);

return (
Expand Down
3 changes: 2 additions & 1 deletion packages/stepper/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { Step, StepProps } from './Step';
export { type BadgeProps } from './StepBadge';
export { Stepper, StepperProps } from './Stepper';
export { Stepper } from './Stepper';
export type { StepKey, StepKeys, StepperProps } from './Stepper';
15 changes: 9 additions & 6 deletions storybook/src/stories/stepper/stepper.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -16,17 +16,20 @@ import * as stories from './stepper.stories';

<Markdown>{readme}</Markdown>

<ArgTypes of={ stories.basic } />
<ArgTypes of={ stories.basicUncontrolled } />

## basic
<Canvas of={ stories.basic } meta={stories} />
## Basic Uncontrolled
<Canvas of={ stories.basicUncontrolled } meta={stories} />

## vertical
<Canvas of={ stories.vertical } meta={stories} />
## Vertical Uncontrolled
<Canvas of={ stories.verticalUncontrolled } meta={stories} />

## controlled
## Controlled
<Canvas of={ stories.controlled } meta={stories} />

## Controlled With External Buttons
<Canvas of={ stories.controlledWithExternalButtons } meta={stories} />

<ChangeLog
changelogs={{
react: changelogReact,
Expand Down
44 changes: 35 additions & 9 deletions storybook/src/stories/stepper/stepper.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ import { useState } from 'react';

import { Meta, StoryObj } from '@storybook/react';

import { Stepper } from '@equinor/fusion-react-stepper/src/Stepper';
import { Step } from '@equinor/fusion-react-stepper/src/Step';
import { StepKeys, Stepper, Step } from '@equinor/fusion-react-stepper';

import { Button } from '@equinor/eds-core-react';
import { faker } from '@faker-js/faker';
Expand All @@ -19,9 +18,9 @@ export default meta;

type Story = StoryObj<typeof Stepper>;

export const basic: Story = {
export const basicUncontrolled: Story = {
args: {
activeStepKey: '0',
initialStepKey: '0',
onChange: (e, k) => console.log('active: ', e, ' keys: ', k),
},
render: (props) => {
Expand All @@ -40,17 +39,44 @@ export const basic: Story = {
},
};

export const vertical: Story = {
...basic,
export const verticalUncontrolled: Story = {
...basicUncontrolled,
args: {
activeStepKey: '0',
initialStepKey: '0',
verticalSteps: true,
},
};

export const controlled: Story = {
args: {
activeStepKey: '0',
initialStepKey: '0',
},
render: (props) => {
const [activeStep, setActiveStep] = useState<string>('0');
const onChangeStep = (stepKey: string, allSteps: StepKeys) => {
console.log('active: ', stepKey, ' keys: ', allSteps);
if (activeStep !== stepKey) {
setActiveStep(String(stepKey));
}
};
faker.seed(1);
return (
<Stepper {...props} stepKey={activeStep} onChange={onChangeStep}>
{[...Array(5)].map((_, index) => (
<Step key={index} title={faker.lorem.word()} description={faker.lorem.words()} stepKey={String(index)}>
<div style={{ padding: 10 }}>
<p>{faker.lorem.paragraphs()}</p>
</div>
</Step>
))}
</Stepper>
);
},
};

export const controlledWithExternalButtons: Story = {
args: {
initialStepKey: '0',
hideNavButtons: true,
forceOrder: true,
},
Expand All @@ -59,7 +85,7 @@ export const controlled: Story = {
const [activeStep, setActiveStep] = useState<number>(0);
const steps = 5;
return (
<Stepper {...props} activeStepKey={String(activeStep)}>
<Stepper {...props} stepKey={String(activeStep)}>
{[...Array(steps)].map((_, index) => (
<Step key={index} title={faker.lorem.word()} description={faker.lorem.words()} stepKey={String(index)}>
<div style={{ padding: 10 }}>
Expand Down

0 comments on commit eba9cf6

Please sign in to comment.