Skip to content

Commit

Permalink
feat(Stepper): various enhancements and refactor (#3781)
Browse files Browse the repository at this point in the history
* feat: stepper enhancements draft

* feat: enhancement stepper

* fix: automatic indexing and more controllable

* fix: refactor code

* fix: update tests

* fix: tests with vitest

* fix: tests

* fix: add children and fix problem with long titles

* fix: use data instead of js prop for style

* fix: rebase and update snapshots
  • Loading branch information
lisalupi committed May 23, 2024
1 parent 3a808ce commit 90dbb2b
Show file tree
Hide file tree
Showing 17 changed files with 5,719 additions and 727 deletions.
9 changes: 9 additions & 0 deletions .changeset/gorgeous-weeks-fail.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@ultraviolet/ui": minor
---

`<Stepper />` enhancement:
- Added a new disabled state
- Added possibility to navigate throughout the steps for the user by clicking on the bullets
- Refactor code : use `<Stepper.Step />` for the steps instead of a list of children
- New style overall
Original file line number Diff line number Diff line change
Expand Up @@ -1564,4 +1564,4 @@ exports[`TimeField > should trigger events 1`] = `
</form>
</div>
</DocumentFragment>
`;
`;
258 changes: 258 additions & 0 deletions packages/ui/src/components/Stepper/Step.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { css, keyframes } from '@emotion/react'
import styled from '@emotion/styled'
import type { ReactNode } from 'react'
import { useMemo } from 'react'
import { Bullet } from '../Bullet'
import { Stack } from '../Stack'
import { Text } from '../Text'
import { useStepper } from './StepperProvider'

const LINE_HEIGHT_SIZES = {
small: 2,
medium: 4,
} as const

type StepProps = {
onClick?: (index: number) => void
/**
* Automatically attribued by the parent Stepper
*/
index?: number
/**
* Whether the step is disabled
*/
disabled?: boolean
/**
* Title of the step
*/
title?: ReactNode
/**
* For additional information.
* Use prop `title` to properly name the step
*/
children?: ReactNode
className?: string
'data-testid'?: string
}
const loadingAnimation = (size: 'small' | 'medium') => keyframes`
from {
width: 0;
}
to {
width: calc(
100% - ${size === 'small' ? '24px' : '32px'} -
8px}
);
}
`

const loadingStyle = (size: 'small' | 'medium') => css`
animation: ${loadingAnimation(size)} 1s linear infinite;
`

const StyledBullet = styled(Bullet)<{
size: 'small' | 'medium'
isActive: boolean
}>`
margin-top: ${({ theme, size }) =>
size === 'small' ? theme.space['0.5'] : 0};
transition: box-shadow 300ms;
min-width: ${({ theme, size }) =>
size === 'small' ? theme.space[3] : theme.space[4]};
${({ theme, isActive }) =>
isActive
? `background-color: ${theme.colors.primary.backgroundStrongHover};
box-shadow: ${theme.shadows.focusPrimary};`
: null};
`

const StyledText = styled(Text)`
margin-top: ${({ theme }) => theme.space['1']};
transition: text-decoration-color 250ms ease-out;
text-decoration-thickness: 1px;
text-underline-offset: 2px;
text-decoration-color: transparent;
white-space: normal;
`

const StyledStepContainer = styled(Stack)<{
'data-disabled': boolean
'data-interactive': boolean
'data-hide-separator': boolean
'data-label-position': 'bottom' | 'right'
size: 'small' | 'medium'
'aria-selected': boolean
'data-done': boolean
'data-animated': boolean
}>`
display: flex;
white-space: nowrap;
transition: text-decoration 300ms;
&[data-interactive='true']:not([data-disabled='true']) {
cursor: pointer;
&[aria-selected='true']:hover {
& > ${StyledBullet} {
box-shadow: ${({ theme }) => theme.shadows.focusPrimary};
& > ${StyledText} {
color: ${({ theme }) => theme.colors.primary.textHover};
text-decoration: underline
${({ theme }) => theme.colors.primary.textHover};
text-decoration-thickness: 1px;
}
}
}
&[data-done='true']:hover {
& > ${StyledBullet} {
box-shadow: ${({ theme }) => theme.shadows.focusPrimary};
}
& > ${StyledText} {
color: ${({ theme }) => theme.colors.neutral.textHover};
text-decoration: underline
${({ theme }) => theme.colors.neutral.textHover};
text-decoration-thickness: 1px;
}
}
}
&[data-disabled='true'] {
cursor: not-allowed;
& > ${StyledText} {
color: ${({ theme }) => theme.colors.neutral.textDisabled};
}
& > ${StyledBullet} {
background-color: ${({ theme }) =>
theme.colors.neutral.backgroundDisabled};
box-shadow: none;
color: ${({ theme }) => theme.colors.neutral.textDisabled};
border-color: ${({ theme }) => theme.colors.neutral.borderDisabled};
}
}
&:not([data-hide-separator='true']):not([data-label-position='right']) {
flex-direction: column;
flex: 1;
&:not(:last-child) {
&:after {
content: '';
position: relative;
align-self: baseline;
border-radius: ${({ theme }) => theme.radii.default};
background-color: ${({ theme }) =>
theme.colors.neutral.backgroundStrong};
top: 20px;
width: calc(
100% - ${({ size }) => (size === 'small' ? '24px' : '32px')} -
${({ theme }) => theme.space[2]}
);
left: calc(50% + 25px);
order: -1;
height: ${({ size }) =>
size === 'small'
? LINE_HEIGHT_SIZES.small
: LINE_HEIGHT_SIZES.medium}px;
}
&[data-done='true']:after {
background-color: ${({ theme }) =>
theme.colors.primary.backgroundStrong};
}
&[aria-selected='true'][data-animated='true']:after {
background-color: ${({ theme }) =>
theme.colors.primary.backgroundStrong};
${({ size }) => loadingStyle(size)}
}
}
&:last-child {
margin-top: ${({ theme, size }) =>
size === 'small' ? '6px' : theme.space[1]};
}
}
`

export const Step = ({
index = 0,
onClick,
disabled = false,
title,
children,
className,
'data-testid': dataTestId,
}: StepProps) => {
const currentState = useStepper()
const isActive = index === currentState.step
const isDone = index < currentState.step

const textVariant = useMemo(() => {
if (currentState.size === 'medium') {
return isActive ? 'bodyStrong' : 'body'
}

return isActive ? 'bodySmallStrong' : 'bodySmall'
}, [currentState.size, isActive])

return (
<StyledStepContainer
gap={currentState.labelPosition === 'right' ? 1 : 0.5}
direction={currentState.labelPosition === 'right' ? 'row' : 'column'}
alignItems={
currentState.labelPosition === 'right' ? 'baseline' : 'center'
}
justifyContent="flex-start"
className={className ?? 'step'}
data-interactive={currentState.interactive && isDone}
onClick={() => {
if (currentState.interactive && !disabled) {
if (index < currentState.step) {
currentState.setStep(index)
}
onClick?.(index)
}
}}
data-disabled={disabled}
data-testid={dataTestId ?? `stepper-step-${index}`}
data-hide-separator={!currentState.separator}
data-label-position={currentState.labelPosition}
size={currentState.size}
aria-selected={isActive}
data-done={isDone}
data-animated={currentState.animated}
>
{isDone && !disabled ? (
<StyledBullet
sentiment="primary"
icon="check"
prominence="strong"
size={currentState.size}
isActive={isActive}
/>
) : (
<StyledBullet
sentiment={isDone || isActive ? 'primary' : 'neutral'}
text={index.toString()}
prominence="strong"
size={currentState.size}
isActive={isActive}
/>
)}
{title ? (
<StyledText
as="span"
variant={textVariant}
prominence={isDone || isActive ? 'default' : 'weak'}
sentiment={isActive ? 'primary' : 'neutral'}
>
{title}
</StyledText>
) : null}
{children ?? null}
</StyledStepContainer>
)
}
66 changes: 66 additions & 0 deletions packages/ui/src/components/Stepper/StepperProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { ReactNode } from 'react'
import { createContext, useContext, useEffect, useMemo, useState } from 'react'

type ContextType = {
step: number
setStep: React.Dispatch<React.SetStateAction<number>>
interactive: boolean
size: 'medium' | 'small'
animated: boolean
labelPosition: 'bottom' | 'right'
separator: boolean
}

const StepperContext = createContext<ContextType>({
step: 0,
setStep: () => {},
interactive: false,
size: 'medium',
animated: false,
labelPosition: 'bottom',
separator: true,
})

type StepperProviderProps = {
children: ReactNode
interactive: boolean
animated: boolean
selected: number
labelPosition: 'bottom' | 'right'
size: 'small' | 'medium'
separator: boolean
}

export const useStepper = () => useContext(StepperContext)
/**
* Stepper component to show the progress of a process in a linear way.
*/
export const StepperProvider = ({
children,
interactive,
selected,
animated,
labelPosition,
size,
separator,
}: StepperProviderProps) => {
const currentSelected = useMemo(() => selected, [selected])
const [step, setStep] = useState(currentSelected)
const value = useMemo(
() => ({
step,
setStep,
interactive,
size,
animated,
labelPosition,
separator,
}),
[step, interactive, size, animated, labelPosition, separator],
)
useEffect(() => setStep(selected), [selected])

return (
<StepperContext.Provider value={value}>{children}</StepperContext.Provider>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { StoryFn } from '@storybook/react'
import { Icon } from '@ultraviolet/icons'
import { Stepper } from '..'
import { Text } from '../../Text'

export const Children: StoryFn<typeof Stepper> = args => (
<Stepper {...args}>
<Stepper.Step title="Step 1">Children</Stepper.Step>
<Stepper.Step title="Step 2">
<Text as="span" variant="body" sentiment="primary">
Custom text!
</Text>
</Stepper.Step>
<Stepper.Step title="Step 3" />
<Stepper.Step title="Step 4" />
<Stepper.Step title="Step 5">
Icon: <Icon name="sun" />
</Stepper.Step>
</Stepper>
)

Children.parameters = {
docs: {
description: {
story: 'You can add children to add more information',
},
},
}

Children.args = {
selected: 3,
interactive: false,
}
Loading

0 comments on commit 90dbb2b

Please sign in to comment.