diff --git a/src/atoms/hooks/hooks.stories.tsx b/src/atoms/hooks/hooks.stories.tsx index 2f120d70a..4f854eb45 100644 --- a/src/atoms/hooks/hooks.stories.tsx +++ b/src/atoms/hooks/hooks.stories.tsx @@ -5,6 +5,17 @@ import { Typography } from 'src/molecules'; import styled from 'styled-components'; const hookList = [ + { + name: 'useStepper', + body: 'Returns stepper methods and state', + code: `const { + steps: [Step, Step, ...Step[]]; + currentStep: number; + currentSubStep: number; + setCurrentStep: (value: number) => void; + goToNextStep: () => void; + goToPreviousStep: () => void; } = useStepper()`, + }, { name: 'useSignalRMessages', body: 'Returns service bus messages with wss given a topic + host + token', diff --git a/src/atoms/hooks/index.ts b/src/atoms/hooks/index.ts index d932cd7ff..cc8e7b12d 100644 --- a/src/atoms/hooks/index.ts +++ b/src/atoms/hooks/index.ts @@ -11,12 +11,14 @@ import { useAuth } from 'src/providers/AuthProvider/AuthProvider'; import { useReleaseNotes } from 'src/providers/ReleaseNotesProvider'; import { useSideBar } from 'src/providers/SideBarProvider'; import { useSnackbar } from 'src/providers/SnackbarProvider/SnackbarProvider'; +import { useStepper } from 'src/providers/StepperProvider'; import { useTableOfContents } from 'src/providers/TableOfContentsProvider'; import { useThemeProvider } from 'src/providers/ThemeProvider/ThemeProvider'; import { useTutorialSteps } from 'src/providers/TutorialStepsProvider'; export { useAuth, + useStepper, useSelect, useDebounce, useFakeProgress, diff --git a/src/molecules/Stepper/Step.tsx b/src/deprecated/OldStepper/OldStep.tsx similarity index 97% rename from src/molecules/Stepper/Step.tsx rename to src/deprecated/OldStepper/OldStep.tsx index e4cb4d219..0b9f98a53 100644 --- a/src/molecules/Stepper/Step.tsx +++ b/src/deprecated/OldStepper/OldStep.tsx @@ -61,7 +61,7 @@ const IconWrapper = styled.span` } `; -interface StepProps { +interface OldStepProps { currentIndex: number; setCurrentIndex: (value: number) => void; index: number; @@ -69,7 +69,7 @@ interface StepProps { children?: string; } -const Step: FC = ({ +export const OldStep: FC = ({ currentIndex, setCurrentIndex, index, @@ -135,5 +135,3 @@ const Step: FC = ({ ); }; - -export default Step; diff --git a/src/deprecated/OldStepper/OldStepLine.tsx b/src/deprecated/OldStepper/OldStepLine.tsx new file mode 100644 index 000000000..c2abcd4f1 --- /dev/null +++ b/src/deprecated/OldStepper/OldStepLine.tsx @@ -0,0 +1,33 @@ +import { FC, useMemo } from 'react'; + +import { tokens } from '@equinor/eds-tokens'; + +import styled from 'styled-components'; + +const { colors } = tokens; + +interface LineProps { + $background: string; +} + +const Line = styled.hr` + height: 1px; + width: 100%; + background: ${(props) => props.$background}; + border: none; + margin: 0; +`; + +interface StepLineProps { + done: boolean; +} + +const OldStepLine: FC = ({ done }) => { + const background = useMemo((): string => { + if (done) return colors.interactive.primary__resting.rgba; + return colors.interactive.disabled__text.rgba; + }, [done]); + return ; +}; + +export default OldStepLine; diff --git a/src/deprecated/OldStepper/OldStepper.stories.tsx b/src/deprecated/OldStepper/OldStepper.stories.tsx new file mode 100644 index 000000000..ba7153fc7 --- /dev/null +++ b/src/deprecated/OldStepper/OldStepper.stories.tsx @@ -0,0 +1,45 @@ +import { Meta, StoryFn } from '@storybook/react'; + +import { OldStepper, OldStepperProps } from './OldStepper'; + +import styled from 'styled-components'; + +const meta: Meta = { + title: 'Deprecated/Stepper', + component: OldStepper, + argTypes: { + current: { control: 'number' }, + setCurrent: { action: 'Called setCurrent' }, + steps: { control: 'object' }, + onlyShowCurrentStepLabel: { control: 'boolean' }, + maxWidth: { control: 'text' }, + }, + args: { + current: 0, + steps: ['Select conveyance', 'Select provider', 'Select service'], + onlyShowCurrentStepLabel: false, + }, + parameters: { + design: { + type: 'figma', + url: 'https://www.figma.com/file/fk8AI59x5HqPCBg4Nemlkl/%F0%9F%92%A0-Component-Library---Amplify?type=design&node-id=5694-19911&mode=design&t=jlQAMMWK1GLpzcAL-4', + }, + }, +}; + +export default meta; + +const Container = styled.div` + height: 20rem; + display: flex; + justify-content: center; + padding: 0 10rem; +`; + +export const Primary: StoryFn = (args) => { + return ( + + + + ); +}; diff --git a/src/deprecated/OldStepper/OldStepper.tsx b/src/deprecated/OldStepper/OldStepper.tsx new file mode 100644 index 000000000..e28aed4c9 --- /dev/null +++ b/src/deprecated/OldStepper/OldStepper.tsx @@ -0,0 +1,75 @@ +import { FC, ReactElement, useMemo } from 'react'; + +import { OldStep } from './OldStep'; +import OldStepLine from './OldStepLine'; +import { spacings } from 'src/atoms/style'; + +import styled from 'styled-components'; + +interface ContainerProps { + $stepAmount: number; + $maxWidth?: string; +} +const Container = styled.div` + display: grid; + grid-template-columns: + repeat(${({ $stepAmount }) => $stepAmount - 1}, auto 1fr) + auto; + grid-gap: ${spacings.small}; + align-items: center; + width: 100%; + ${({ $maxWidth }) => $maxWidth && `max-width: ${$maxWidth}`} +`; + +export interface OldStepperProps { + current: number; + setCurrent: (value: number) => void; + steps: string[]; + onlyShowCurrentStepLabel?: boolean; + maxWidth?: string; +} + +/** + * @deprecated Use the new Stepper + StepperProvider component instead + */ +export const OldStepper: FC = ({ + current, + setCurrent, + steps, + onlyShowCurrentStepLabel = false, + maxWidth, +}) => { + const children = useMemo((): ReactElement[] => { + const all: ReactElement[] = []; + steps.forEach((step, index) => { + all.push( + + {step} + + ); + + if (index !== steps.length - 1) { + all.push( + index} /> + ); + } + }); + return all; + }, [current, onlyShowCurrentStepLabel, setCurrent, steps]); + + return ( + + {children} + + ); +}; diff --git a/src/deprecated/index.ts b/src/deprecated/index.ts index 3647aa66a..d670a33bc 100644 --- a/src/deprecated/index.ts +++ b/src/deprecated/index.ts @@ -28,4 +28,5 @@ export { default as SingleSelectDrawer } from './SingleSelectDrawer'; export { default as IconToggleButton } from './IconToggleButton'; export { default as Tutorial } from './Tutorial/Tutorial'; export { HighlightBlocks } from './Tutorial/HighlightBlocks/HighlightBlocks'; +export { OldStepper } from './OldStepper/OldStepper'; export { FullPageStatus } from './FullPageStatus/FullPageStatus'; diff --git a/src/molecules/Stepper/Step/Step.tsx b/src/molecules/Stepper/Step/Step.tsx new file mode 100644 index 000000000..cd57cde0f --- /dev/null +++ b/src/molecules/Stepper/Step/Step.tsx @@ -0,0 +1,88 @@ +import { FC, useMemo, useState } from 'react'; + +import { Typography } from '@equinor/eds-core-react'; +import { TypographyVariants } from '@equinor/eds-core-react/dist/types/components/Typography/Typography.tokens'; + +import { colors, spacings } from 'src/atoms/style'; +import { StepIcon } from 'src/molecules/Stepper/Step/StepIcon'; +import { useStepper } from 'src/providers/StepperProvider'; + +import styled from 'styled-components'; + +interface ContainerProps { + $clickable: boolean; +} + +const Container = styled.div` + display: flex; + gap: ${spacings.small}; + align-items: center; + white-space: nowrap; + ${(props) => + props.$clickable && + ` + &:hover { + cursor: pointer; + } + `} +`; + +interface StepProps { + index: number; + onlyShowCurrentStepLabel?: boolean; + children?: string; +} + +export const Step: FC = ({ + index, + onlyShowCurrentStepLabel = false, + children, +}) => { + const { currentStep, setCurrentStep } = useStepper(); + const [containerRef, setContainerRef] = useState(null); + + const textVariant = useMemo((): TypographyVariants => { + if (index < currentStep) return 'body_short'; + return 'body_short_bold'; + }, [currentStep, index]); + + const textColor = useMemo((): string | undefined => { + if (index > currentStep) return colors.interactive.disabled__text.rgba; + return colors.text.static_icons__default.rgba; + }, [currentStep, index]); + + const handleOnClick = () => { + if (index < currentStep) { + setCurrentStep(index); + } + }; + + return ( + { + if (containerRef === null && ref !== null) { + setContainerRef(ref); + } + }} + style={ + containerRef !== null && !onlyShowCurrentStepLabel + ? { + width: `calc(${containerRef.clientWidth}px)`, + } + : undefined + } + $clickable={index < currentStep} + onClick={handleOnClick} + > + + {(!onlyShowCurrentStepLabel || currentStep === index) && ( + + {children} + + )} + + ); +}; + +export default Step; diff --git a/src/molecules/Stepper/Step/StepIcon.tsx b/src/molecules/Stepper/Step/StepIcon.tsx new file mode 100644 index 000000000..c01a8b1ec --- /dev/null +++ b/src/molecules/Stepper/Step/StepIcon.tsx @@ -0,0 +1,63 @@ +import { FC } from 'react'; + +import { Icon, Typography } from '@equinor/eds-core-react'; +import { check } from '@equinor/eds-icons'; + +import { colors, shape } from 'src/atoms/style'; +import { useStepper } from 'src/providers/StepperProvider'; + +import styled from 'styled-components'; + +interface IconWrapperProps { + $filled?: boolean; + $outlined?: boolean; +} + +const IconWrapper = styled.span` + display: flex; + justify-content: center; + align-items: center; + width: 22px; + height: 22px; + border-radius: ${shape.circle.borderRadius}; + border: 2px solid + ${({ $filled, $outlined }) => + ($filled ?? $outlined) + ? colors.interactive.primary__resting.rgba + : colors.interactive.disabled__text.rgba}; + background: ${({ $filled }) => + $filled ? colors.interactive.primary__resting.rgba : 'none'}; + > p { + // Ensure text icons are not squished + padding: 8px; + color: ${(props) => + props.$filled + ? colors.text.static_icons__primary_white.rgba + : colors.interactive.disabled__text.rgba}; + } + > svg { + transform: scale(0.9); + } +`; + +interface StepIconProps { + index: number; +} + +export const StepIcon: FC = ({ index }) => { + const { currentStep } = useStepper(); + + if (index >= currentStep) { + return ( + + {index + 1} + + ); + } + + return ( + + + + ); +}; diff --git a/src/molecules/Stepper/StepLine.tsx b/src/molecules/Stepper/StepLine.tsx index 6753227c1..bfaf31807 100644 --- a/src/molecules/Stepper/StepLine.tsx +++ b/src/molecules/Stepper/StepLine.tsx @@ -22,12 +22,10 @@ interface StepLineProps { done: boolean; } -const StepLine: FC = ({ done }) => { +export const StepLine: FC = ({ done }) => { const background = useMemo((): string => { if (done) return colors.interactive.primary__resting.rgba; return colors.interactive.disabled__text.rgba; }, [done]); return ; }; - -export default StepLine; diff --git a/src/molecules/Stepper/Stepper.stories.tsx b/src/molecules/Stepper/Stepper.stories.tsx index 17f27c236..be20e0edd 100644 --- a/src/molecules/Stepper/Stepper.stories.tsx +++ b/src/molecules/Stepper/Stepper.stories.tsx @@ -1,6 +1,9 @@ +import { Button } from '@equinor/eds-core-react'; import { Meta, StoryFn } from '@storybook/react'; +import { spacings } from 'src/atoms'; import { Stepper, StepperProps } from 'src/molecules/Stepper/Stepper'; +import { StepperProvider, useStepper } from 'src/providers/StepperProvider'; import styled from 'styled-components'; @@ -8,38 +11,87 @@ const meta: Meta = { title: 'Molecules/Stepper', component: Stepper, argTypes: { - current: { control: 'number' }, - setCurrent: { action: 'Called setCurrent' }, - steps: { control: 'object' }, onlyShowCurrentStepLabel: { control: 'boolean' }, maxWidth: { control: 'text' }, }, args: { - current: 0, - steps: ['Select conveyance', 'Select provider', 'Select service'], onlyShowCurrentStepLabel: false, }, parameters: { design: { type: 'figma', - url: 'https://www.figma.com/file/fk8AI59x5HqPCBg4Nemlkl/%F0%9F%92%A0-Component-Library---Amplify?type=design&node-id=5694-19911&mode=design&t=jlQAMMWK1GLpzcAL-4', + url: 'https://www.figma.com/design/fk8AI59x5HqPCBg4Nemlkl/%F0%9F%92%A0-Component-Library---Amplify?node-id=5694-19911&m=dev', }, }, + decorators: (Story) => ( + + + + ), }; export default meta; const Container = styled.div` - height: 20rem; display: flex; + flex-direction: column; justify-content: center; padding: 0 10rem; + > section { + display: flex; + justify-content: flex-end; + margin-top: ${spacings.xxx_large}; + gap: ${spacings.medium}; + } `; export const Primary: StoryFn = (args) => { + const { steps, goToNextStep, goToPreviousStep, currentStep } = useStepper(); + return ( +
+ + +
); }; diff --git a/src/molecules/Stepper/Stepper.test.tsx b/src/molecules/Stepper/Stepper.test.tsx index 97f5cb474..bbc1f3836 100644 --- a/src/molecules/Stepper/Stepper.test.tsx +++ b/src/molecules/Stepper/Stepper.test.tsx @@ -1,45 +1,76 @@ +import { FC } from 'react'; + import { check } from '@equinor/eds-icons'; -import { tokens } from '@equinor/eds-tokens'; import { faker } from '@faker-js/faker'; +import { colors } from 'src/atoms/style'; import { Stepper, StepperProps } from 'src/molecules/Stepper/Stepper'; +import { + StepperProvider, + StepperProviderProps, + useStepper, +} from 'src/providers/StepperProvider'; import { render, screen, userEvent } from 'src/tests/test-utils'; -const { colors } = tokens; - -function fakeProps(): StepperProps { - const steps: string[] = []; +function fakeSteps(): StepperProviderProps['steps'] { + const steps: StepperProviderProps['steps'] = [ + { + label: faker.string.uuid(), + }, + { + label: faker.string.uuid(), + }, + ]; let i = 0; - const stepAmount = faker.number.int({ min: 2, max: 30 }); + const stepAmount = faker.number.int({ min: 0, max: 30 }); while (i < stepAmount) { i += 1; - steps.push(faker.string.uuid()); + steps.push({ + label: faker.string.uuid(), + }); } - return { - current: 0, - setCurrent: vi.fn(), - steps, - }; + + return steps; } -test('Displays icon/number correctly', () => { - const props = fakeProps(); - const { rerender } = render(); +const StepperTestComponent: FC = (props) => { + const { currentStep, goToPreviousStep, goToNextStep } = useStepper(); + + return ( +
+ +

{`Current step: ${currentStep}`}

+ + +
+ ); +}; + +test('Displays icon/number correctly', async () => { + const steps = fakeSteps(); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + const user = userEvent.setup(); // Current does not have color attribute - const firstElement = screen.getByText(props.steps[0]); + const firstElement = screen.getByText(steps[0].label); expect(firstElement).toHaveStyleRule( 'color', colors.text.static_icons__default.rgba ); - for (const step of props.steps.slice(1)) { - expect(screen.getByText(step)).toHaveStyleRule( + for (const step of steps.slice(1)) { + expect(screen.getByText(step.label)).toHaveStyleRule( 'color', colors.interactive.disabled__text.rgba ); } - rerender(); + + await user.click(screen.getByText('Next')); expect(screen.getByTestId('eds-icon-path')).toHaveAttribute( 'd', @@ -47,32 +78,181 @@ test('Displays icon/number correctly', () => { ); }); -test('Fires onClick when clicking steps that are in the past', async () => { +test('Clicking through shows all steps', async () => { + const steps = fakeSteps(); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + const user = userEvent.setup(); + + for (let i = 0; i < steps.length; i++) { + expect(screen.getByText(`Current step: ${i}`)).toBeInTheDocument(); + await user.click(screen.getByText('Next')); + } +}); + +test('Clicking past the last step does nothing', async () => { + const steps = fakeSteps(); + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + const user = userEvent.setup(); + + expect( + screen.getByText(`Current step: ${steps.length - 1}`) + ).toBeInTheDocument(); + + await user.click(screen.getByText('Next')); + + expect( + screen.getByText(`Current step: ${steps.length - 1}`) + ).toBeInTheDocument(); +}); + +test('Clicking previous on the first step does nothing', async () => { + const steps = fakeSteps(); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + const user = userEvent.setup(); + + expect(screen.getByText(`Current step: 0`)).toBeInTheDocument(); + + await user.click(screen.getByText('Previous')); + + expect(screen.getByText(`Current step: 0`)).toBeInTheDocument(); +}); + +test('Clicking a previous step via the label works as expected', async () => { + const steps = fakeSteps(); + render(, { + wrapper: ({ children }) => ( + + {children} + + ), + }); + const user = userEvent.setup(); - const props = fakeProps(); - render(); - const steps = screen.getAllByTestId('step'); + expect( + screen.getByText(`Current step: ${steps.length - 1}`) + ).toBeInTheDocument(); + + await user.click(screen.getByText(steps[0].label)); + + expect(screen.getByText(`Current step: 0`)).toBeInTheDocument(); +}); + +test('maxWidth works as expected', () => { + const steps = fakeSteps(); + const maxWidth = '800px'; + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + expect(screen.getByTestId('stepper-container')).toHaveStyleRule( + 'max-width', + maxWidth + ); +}); - await user.click(steps[0]); +test('onlyShowCurrentLabel works as expected', () => { + const steps = fakeSteps(); + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); - // 0 is the index of the element we clicked - expect(props.setCurrent).toHaveBeenCalledWith(0); - expect(props.setCurrent).toHaveBeenCalledTimes(1); + for (let i = 0; i < steps.length; i++) { + if (i === 0) { + expect(screen.getByText(steps[i].label)).toBeInTheDocument(); + } else { + expect(screen.queryByText(steps[i].label)).not.toBeInTheDocument(); + } + } }); -test('onlyShowCurrentStepLabel prop hides other labels', () => { - const props = fakeProps(); - render(); +test('Works as expected with substeps', async () => { + const steps: StepperProviderProps['steps'] = [ + { + label: 'Step 1', + subSteps: [ + { + title: faker.animal.dog(), + description: faker.lorem.sentence(), + }, + { + title: faker.animal.cat(), + }, + ], + }, + { + label: 'Step 2', + }, + ]; + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); + + const user = userEvent.setup(); + + expect(screen.getByText(`1 of 2`)).toBeInTheDocument(); + expect(screen.getByText(steps[0].subSteps![0].title)).toBeInTheDocument(); + expect( + screen.getByText(steps[0].subSteps![0].description!) + ).toBeInTheDocument(); + + await user.click(screen.getByText('Next')); + + expect(screen.getByText(steps[0].subSteps![1].title)).toBeInTheDocument(); + + await user.click(screen.getByText('Next')); + + expect(screen.getByText(`Current step: 1`)).toBeInTheDocument(); + + await user.click(screen.getByText('Previous')); + + expect(screen.getByText(steps[0].subSteps![1].title)).toBeInTheDocument(); + + await user.click(screen.getByText('Previous')); - expect(screen.queryByText(props.steps[1])).toBeNull(); + expect(screen.getByText(steps[0].subSteps![0].title)).toBeInTheDocument(); }); -test('maxWidth props sets max-width style', () => { - const width = faker.number.int() + 'px'; - const props = fakeProps(); - render(); +test('Works as expected with title in steps', () => { + const steps: StepperProviderProps['steps'] = [ + { + label: 'Step 1', + title: faker.animal.bear(), + description: faker.animal.dog(), + }, + { + label: 'Step 2', + }, + ]; + render(, { + wrapper: ({ children }) => ( + {children} + ), + }); - const container = screen.getByTestId('stepper-container'); - expect(container).toHaveStyleRule('max-width', width); + expect(screen.getByText(steps[0].title!)).toBeInTheDocument(); + expect(screen.getByText(steps[0].description!)).toBeInTheDocument(); }); diff --git a/src/molecules/Stepper/Stepper.tsx b/src/molecules/Stepper/Stepper.tsx index 10ef51251..77eb20d90 100644 --- a/src/molecules/Stepper/Stepper.tsx +++ b/src/molecules/Stepper/Stepper.tsx @@ -1,11 +1,19 @@ import { FC, ReactElement, useMemo } from 'react'; +import { Step } from './Step/Step'; +import { StepLine } from './StepLine'; import { spacings } from 'src/atoms/style'; -import Step from 'src/molecules/Stepper/Step'; -import StepLine from 'src/molecules/Stepper/StepLine'; +import { SubTitle } from 'src/molecules/Stepper/SubTitle/SubTitle'; +import { useStepper } from 'src/providers/StepperProvider'; import styled from 'styled-components'; +const Wrapper = styled.div` + display: flex; + flex-direction: column; + gap: ${spacings.xx_large}; +`; + interface ContainerProps { $stepAmount: number; $maxWidth?: string; @@ -22,51 +30,48 @@ const Container = styled.div` `; export interface StepperProps { - current: number; - setCurrent: (value: number) => void; - steps: string[]; onlyShowCurrentStepLabel?: boolean; maxWidth?: string; } export const Stepper: FC = ({ - current, - setCurrent, - steps, onlyShowCurrentStepLabel = false, maxWidth, }) => { - const children = useMemo((): ReactElement[] => { + const { steps, currentStep } = useStepper(); + + const content = useMemo((): ReactElement[] => { const all: ReactElement[] = []; steps.forEach((step, index) => { all.push( - {step} + {step.label} ); if (index !== steps.length - 1) { all.push( - index} /> + index} /> ); } }); return all; - }, [current, onlyShowCurrentStepLabel, setCurrent, steps]); + }, [currentStep, onlyShowCurrentStepLabel, steps]); return ( - - {children} - + + + {content} + + + ); }; diff --git a/src/molecules/Stepper/SubTitle/SubStepIndicator.tsx b/src/molecules/Stepper/SubTitle/SubStepIndicator.tsx new file mode 100644 index 000000000..768456829 --- /dev/null +++ b/src/molecules/Stepper/SubTitle/SubStepIndicator.tsx @@ -0,0 +1,56 @@ +import { FC } from 'react'; + +import { animation, colors, shape, spacings } from 'src/atoms/style'; +import { useStepper } from 'src/providers/StepperProvider'; + +import styled from 'styled-components'; + +interface ContainerProps { + $amountOfSubSteps: number; +} + +const Container = styled.div` + display: grid; + grid-template-columns: repeat( + ${({ $amountOfSubSteps }) => $amountOfSubSteps}, + 1fr + ); + gap: ${spacings.small}; + width: 300px; +`; + +interface LineProps { + $currentIndex: number; + $index: number; +} + +const Line = styled.span` + width: 100%; + height: 5px; + position: relative; + background: ${colors.interactive.primary__hover_alt.rgba}; + border-radius: ${shape.rounded.borderRadius}; + overflow: hidden; + &:after { + content: ''; + background: ${colors.interactive.primary__resting.rgba}; + position: absolute; + width: 100%; + height: 100%; + transition: left ${animation.transitionMS}; + left: ${({ $index, $currentIndex }) => ($currentIndex - $index) * 100}%; + } +`; + +export const SubStepIndicator: FC = () => { + const { currentSubStep, steps, currentStep } = useStepper(); + const amountOfSubSteps = steps[currentStep].subSteps!.length; + + return ( + + {new Array(amountOfSubSteps).fill(null).map((_, index) => ( + + ))} + + ); +}; diff --git a/src/molecules/Stepper/SubTitle/SubTitle.tsx b/src/molecules/Stepper/SubTitle/SubTitle.tsx new file mode 100644 index 000000000..21aeed0f1 --- /dev/null +++ b/src/molecules/Stepper/SubTitle/SubTitle.tsx @@ -0,0 +1,59 @@ +import { FC } from 'react'; + +import { colors, spacings } from 'src/atoms/style'; +import { Typography } from 'src/molecules'; +import { SubStepIndicator } from 'src/molecules/Stepper/SubTitle/SubStepIndicator'; +import { useStepper } from 'src/providers/StepperProvider'; + +import styled from 'styled-components'; + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: ${spacings.small}; + align-self: center; + align-items: center; +`; + +export const SubTitle: FC = () => { + const { currentStep, currentSubStep: subStepIndex, steps } = useStepper(); + const currentSubSteps = + 'subSteps' in steps[currentStep] ? steps[currentStep].subSteps : []; + const currentSubStep = currentSubSteps?.at(subStepIndex); + + if ( + (!currentSubStep && steps[currentStep].title === undefined) || + !currentSubSteps + ) { + return null; + } + + if (steps[currentStep].title) { + return ( + + {steps[currentStep].title} + {steps[currentStep].description && ( + + {steps[currentStep].description} + + )} + + ); + } + + return ( + + + {`${subStepIndex + 1} of ${currentSubSteps.length}`} + + {currentSubStep?.title} + {currentSubStep?.description && ( + {currentSubStep.description} + )} + + + ); +}; diff --git a/src/organisms/Filter/Filter.test.tsx b/src/organisms/Filter/Filter.test.tsx index 9d2f7a377..f4e442f7a 100644 --- a/src/organisms/Filter/Filter.test.tsx +++ b/src/organisms/Filter/Filter.test.tsx @@ -9,7 +9,7 @@ function fakeProps(): Omit, 'children'> { .fill(null) .map(() => ({ key: faker.string.uuid(), - label: faker.animal.dog(), + label: faker.string.uuid(), })), onClearAllFilters: vi.fn(), onClearFilter: vi.fn(), diff --git a/src/providers/StepperProvider.test.tsx b/src/providers/StepperProvider.test.tsx new file mode 100644 index 000000000..67f36e02e --- /dev/null +++ b/src/providers/StepperProvider.test.tsx @@ -0,0 +1,50 @@ +import { renderHook } from '@testing-library/react'; + +import { StepperProvider, useStepper } from 'src/providers/StepperProvider'; +import { render } from 'src/tests/test-utils'; + +test('"useStepper" throws error if used outside of provider', async () => { + console.error = vi.fn(); + + expect(() => renderHook(() => useStepper())).toThrowError(); +}); + +test('Providing illegal initial step throws error', async () => { + console.error = vi.fn(); + + expect(() => + render( + +

children

+
+ ) + ).toThrowError(); + + expect(() => + render( + +

children

+
+ ) + ).toThrowError(); +}); diff --git a/src/providers/StepperProvider.tsx b/src/providers/StepperProvider.tsx new file mode 100644 index 000000000..65df990f2 --- /dev/null +++ b/src/providers/StepperProvider.tsx @@ -0,0 +1,119 @@ +import { createContext, FC, ReactNode, useContext, useState } from 'react'; + +interface SubStep { + title: string; + description?: string; +} + +interface StepWithSubSteps { + label: string; + title?: undefined; + description?: undefined; + subSteps: [SubStep, SubStep, ...SubStep[]]; +} + +interface StepWithoutSubSteps { + label: string; + title?: string; + description?: string; + subSteps?: undefined; +} + +type Step = StepWithSubSteps | StepWithoutSubSteps; + +interface StepperContextType { + steps: [Step, Step, ...Step[]]; + currentStep: number; + currentSubStep: number; + setCurrentStep: (value: number) => void; + goToNextStep: () => void; + goToPreviousStep: () => void; +} + +export const StepperContext = createContext( + undefined +); + +export function useStepper() { + const context = useContext(StepperContext); + if (!context) { + throw new Error('useStepper must be used within a StepperProvider'); + } + return context; +} + +export interface StepperProviderProps { + initialStep?: number; + steps: [Step, Step, ...Step[]]; + children: ReactNode; +} + +export const StepperProvider: FC = ({ + initialStep = 0, + steps, + children, +}) => { + const [currentStep, setCurrentStep] = useState(initialStep); + const [currentSubStep, setCurrentSubStep] = useState(0); + + if (initialStep >= steps.length || initialStep < 0) { + throw new Error('initialStep must be a valid index of the steps array'); + } + + const resetCurrentSubStep = () => { + if (currentSubStep !== 0) setCurrentSubStep(0); + }; + + const goToNextStep = () => { + if ( + 'subSteps' in steps[currentStep] && + steps[currentStep].subSteps && + currentSubStep < steps[currentStep].subSteps.length - 1 + ) { + setCurrentSubStep((prev) => prev + 1); + return; + } + + if (currentStep === steps.length - 1) return; + + setCurrentStep((prev) => prev + 1); + resetCurrentSubStep(); + }; + + const goToPreviousStep = () => { + if ( + currentSubStep > 0 && + 'subSteps' in steps[currentStep] && + steps[currentStep].subSteps && + steps[currentStep].subSteps.length > 0 + ) { + setCurrentSubStep((prev) => prev - 1); + return; + } + + if (currentStep === 0) return; + + setCurrentStep((prev) => prev - 1); + + const previousStep = steps.at(currentStep - 1); + if (previousStep && 'subSteps' in previousStep && previousStep.subSteps) { + // Set substeps to the last substep of the previous step + setCurrentSubStep(previousStep.subSteps.length - 1); + } + }; + + return ( + + {children} + + ); +}; diff --git a/src/providers/index.ts b/src/providers/index.ts index b2b551284..6d700e851 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -6,5 +6,6 @@ export { ReleaseNotesProvider } from './ReleaseNotesProvider'; export { TutorialStepsProvider } from './TutorialStepsProvider'; export { LoadingProvider } from './LoadingProvider'; export { ThemeProvider } from './ThemeProvider/ThemeProvider'; +export { StepperProvider } from './StepperProvider'; export type { TableOfContentsItemType } from './TableOfContentsProvider'; diff --git a/src/providers/providers.stories.tsx b/src/providers/providers.stories.tsx index 7f677659f..437b88475 100644 --- a/src/providers/providers.stories.tsx +++ b/src/providers/providers.stories.tsx @@ -5,6 +5,11 @@ import { Typography } from 'src/molecules'; import styled from 'styled-components'; const providersList = [ + { + name: 'StepperProvider', + body: 'Provider to make stepper work', + code: `{children}`, + }, { name: 'ThemeProvider', body: 'Provider to make Theme (data-theme driven) work', diff --git a/src/tests/mockHandlers.ts b/src/tests/mockHandlers.ts index 6d5290b22..9e07a7088 100644 --- a/src/tests/mockHandlers.ts +++ b/src/tests/mockHandlers.ts @@ -64,7 +64,7 @@ export const FAKE_ROLES: GraphAppRole[] = [ ] as const; function fakeUser(): ImpersonateUserDto { - const firstName = faker.person.firstName(); + const firstName = faker.string.uuid(); const lastName = faker.person.lastName(); const fullName = `${firstName} ${lastName}`; const uniqueName = faker.internet.userName();