Skip to content

Instantly share code, notes, and snippets.

@vinayakakv
Created August 16, 2022 04:06
Show Gist options
  • Save vinayakakv/1e2a333eb1045088ff0f274fddf192f4 to your computer and use it in GitHub Desktop.
Save vinayakakv/1e2a333eb1045088ff0f274fddf192f4 to your computer and use it in GitHub Desktop.
Mantine vertical stepper

Mantine UI Vertical Stepper Modification

I came across an usecase where I needed to implement the checkout flow for an ecommerce website, which needed StepContents to be placed in between the Step UI.

Since it is not currently possible in Mantine UI, I copied the source and placed it in override folder under src/ of my codebase.

You can use it as:

import { Stepper } from "../path/to/override/folder/Stepper"

The API remains same as the official Mantine one.

export { Stepper } from "./stepper"
import {
createStyles,
MantineColor,
MantineNumberSize,
MantineSize,
} from "@mantine/styles"
export interface StepStylesParams {
color: MantineColor
iconSize: number
size: MantineSize
radius: MantineNumberSize
allowStepClick: boolean
iconPosition: "right" | "left"
}
export const iconSizes = {
xs: 34,
sm: 36,
md: 42,
lg: 48,
xl: 52,
}
export default createStyles(
(
theme,
{
color,
iconSize,
size,
radius,
allowStepClick,
iconPosition,
}: StepStylesParams,
getRef
) => {
const _iconSize = iconSize || theme.fn.size({ size, sizes: iconSizes })
const iconMargin =
size === "xl" || size === "lg" ? theme.spacing.md : theme.spacing.sm
const _radius = theme.fn.size({ size: radius, sizes: theme.radius })
return {
stepLoader: {},
step: {
display: "flex",
alignItems: "center",
flexDirection: iconPosition === "left" ? "row" : "row-reverse",
cursor: allowStepClick ? "pointer" : "default",
},
stepIcon: {
ref: getRef("stepIcon"),
boxSizing: "border-box",
height: _iconSize,
width: _iconSize,
minWidth: _iconSize,
borderRadius: _radius,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor:
theme.colorScheme === "dark"
? theme.colors.dark[5]
: theme.colors.gray[1],
border: `2px solid ${
theme.colorScheme === "dark"
? theme.colors.dark[5]
: theme.colors.gray[1]
}`,
transition: "background-color 150ms ease, border-color 150ms ease",
position: "relative",
fontWeight: 700,
color:
theme.colorScheme === "dark"
? theme.colors.dark[1]
: theme.colors.gray[7],
fontSize: theme.fn.size({ size, sizes: theme.fontSizes }),
},
stepCompletedIcon: {
...theme.fn.cover(),
display: "flex",
alignItems: "center",
justifyContent: "center",
color: theme.white,
},
stepProgress: {
[`& .${getRef("stepIcon")}`]: {
borderColor: theme.fn.themeColor(
color,
theme.colorScheme === "dark" ? 7 : 6
),
},
},
stepCompleted: {
[`& .${getRef("stepIcon")}`]: {
backgroundColor: theme.fn.themeColor(
color,
theme.colorScheme === "dark" ? 7 : 6
),
borderColor: theme.fn.themeColor(
color,
theme.colorScheme === "dark" ? 7 : 6
),
color: theme.white,
},
},
stepBody: {
marginLeft: iconPosition === "left" ? iconMargin : undefined,
marginRight: iconPosition === "right" ? iconMargin : undefined,
},
stepLabel: {
textAlign: iconPosition,
fontWeight: 500,
fontSize: theme.fn.size({ size, sizes: theme.fontSizes }),
lineHeight: 1,
},
stepDescription: {
textAlign: iconPosition,
marginTop: theme.fn.size({ size, sizes: theme.spacing }) / 3,
fontSize: theme.fn.size({ size, sizes: theme.fontSizes }) - 2,
lineHeight: 1,
},
}
}
)
import React from "react"
export interface StepCompletedProps {
/** Label content */
children: React.ReactNode
}
export function StepCompleted(
// Props should be kept for ts integration
props: StepCompletedProps
) {
return null
}
StepCompleted.displayName = "@mantine/core/StepCompleted"
import {
createStyles,
MantineNumberSize,
MantineColor,
MantineSize,
} from "@mantine/styles"
import { iconSizes } from "./step.styles"
export interface StepperStylesParams {
contentPadding: MantineNumberSize
iconSize?: number
size: MantineSize
color: MantineColor
orientation: "vertical" | "horizontal"
iconPosition: "right" | "left"
breakpoint: MantineNumberSize
}
export default createStyles(
(
theme,
{
contentPadding,
color,
orientation,
iconPosition,
iconSize,
size,
breakpoint,
}: StepperStylesParams
) => {
const shouldBeResponsive = typeof breakpoint !== "undefined"
const breakpointValue = theme.fn.size({
size: breakpoint,
sizes: theme.breakpoints,
})
const separatorOffset =
typeof iconSize !== "undefined"
? iconSize / 2 - 1
: theme.fn.size({ size, sizes: iconSizes }) / 2 - 1
const verticalOrientationStyles = {
steps: {
flexDirection: "column",
alignItems: "stretch",
},
separator: {
width: 2,
minHeight: theme.spacing.xl,
marginLeft: iconPosition === "left" ? separatorOffset : 0,
marginRight: iconPosition === "right" ? separatorOffset : 0,
marginTop: theme.spacing.xs / 2,
marginBottom: theme.spacing.xs / 2,
},
} as const
const responsiveStyles = {
steps: {
[`@media (max-width: ${breakpointValue}px)`]:
verticalOrientationStyles.steps,
},
separator: {
[`@media (max-width: ${breakpointValue}px)`]:
verticalOrientationStyles.separator,
},
} as const
return {
root: {},
steps: {
display: "flex",
boxSizing: "border-box",
alignItems: "center",
...(orientation === "vertical"
? verticalOrientationStyles.steps
: null),
...(shouldBeResponsive ? responsiveStyles.steps : null),
},
separator: {
boxSizing: "border-box",
transition: "background-color 150ms ease",
flex: 1,
height: 2,
backgroundColor:
theme.colorScheme === "dark"
? theme.colors.dark[4]
: theme.colors.gray[2],
marginLeft: theme.spacing.md,
marginRight: theme.spacing.md,
...(orientation === "vertical"
? verticalOrientationStyles.separator
: null),
...(shouldBeResponsive ? responsiveStyles.separator : null),
},
separatorActive: {
backgroundColor: theme.fn.themeColor(
color,
theme.colorScheme === "dark" ? 7 : 6
),
},
content: {
...theme.fn.fontStyles(),
paddingTop: theme.fn.size({
size: contentPadding,
sizes: theme.spacing,
}),
},
}
}
)
import React, { forwardRef } from "react"
import {
MantineColor,
DefaultProps,
MantineNumberSize,
MantineSize,
ClassNames,
useMantineDefaultProps,
} from "@mantine/styles"
import { findChildByType, filterChildrenByType, Box, Step } from "@mantine/core"
import { StepCompleted } from "./stepCompleted"
import useStyles from "./stepper.styles"
import { default as useStepStyles } from "./step.styles"
export type StepperStylesNames =
| ClassNames<typeof useStyles>
| ClassNames<typeof useStepStyles>
export interface StepperProps
extends DefaultProps<StepperStylesNames>,
React.ComponentPropsWithRef<"div"> {
/** <Stepper.Step /> components only */
children: React.ReactNode
/** Called when step is clicked */
onStepClick?(stepIndex: number): void
/** Active step index */
active: number
/** Step icon displayed when step is completed */
completedIcon?: React.ReactNode
/** Step icon displayed when step is in progress */
progressIcon?: React.ReactNode
/** Active and progress Step colors from theme.colors */
color?: MantineColor
/** Step icon size in px */
iconSize?: number
/** Content padding-top from theme.spacing or number to set value in px */
contentPadding?: MantineNumberSize
/** Component orientation */
orientation?: "vertical" | "horizontal"
/** Icon position relative to step body */
iconPosition?: "right" | "left"
/** Component size */
size?: MantineSize
/** Radius from theme.radius, or number to set border-radius in px */
radius?: MantineNumberSize
/** Breakpoint at which orientation will change from horizontal to vertical */
breakpoint?: MantineNumberSize
}
type StepperComponent = ((props: StepperProps) => React.ReactElement) & {
displayName: string
Step: typeof Step
Completed: typeof StepCompleted
}
type RemoveUndefined<T> = {
[P in keyof T]-?: NonNullable<T[P]>
}
const defaultProps: Partial<StepperProps> = {
contentPadding: "md",
size: "md",
radius: "xl",
orientation: "horizontal",
iconPosition: "left",
}
export const Stepper: StepperComponent = forwardRef<
HTMLDivElement,
StepperProps
>((props: StepperProps, ref) => {
const {
className,
children,
onStepClick,
active,
completedIcon,
progressIcon,
color,
iconSize,
contentPadding,
size,
radius,
orientation,
breakpoint,
iconPosition,
classNames,
styles,
...others
} = useMantineDefaultProps("Stepper", defaultProps, props)
const styleArgs = {
contentPadding,
color,
orientation,
iconPosition,
size,
iconSize,
breakpoint,
}
const { classes, cx } = useStyles(
styleArgs as RemoveUndefined<typeof styleArgs>,
{
classNames,
styles,
name: "Stepper",
}
)
const filteredChildren = filterChildrenByType(children, Step)
const completedStep = findChildByType(children, StepCompleted)
const stepContent = filteredChildren[active]?.props?.children
const completedContent = completedStep?.props?.children
const content = stepContent
const items = filteredChildren.reduce<React.ReactNode[]>(
(acc, item, index, array) => {
const shouldAllowSelect =
typeof item.props.allowStepSelect === "boolean"
? item.props.allowStepSelect
: typeof onStepClick === "function"
acc.push(
<Step
{...item.props}
__staticSelector="Stepper"
icon={item.props.icon || index + 1}
key={index}
state={
active === index
? "stepProgress"
: active > index
? "stepCompleted"
: "stepInactive"
}
onClick={() =>
shouldAllowSelect &&
typeof onStepClick === "function" &&
onStepClick(index)
}
allowStepClick={
shouldAllowSelect && typeof onStepClick === "function"
}
completedIcon={item.props.completedIcon || completedIcon}
progressIcon={item.props.progressIcon || progressIcon}
color={item.props.color || color}
iconSize={iconSize}
size={size}
radius={radius}
classNames={classNames}
styles={styles}
iconPosition={item.props.iconPosition || iconPosition}
/>
)
if (content && active === index) {
acc.push(content)
}
if (index !== array.length - 1) {
acc.push(
<div
className={cx(classes.separator, {
[classes.separatorActive]: index < active,
})}
key={`separator-${index}`}
/>
)
}
return acc
},
[]
)
return (
<Box className={cx(classes.root, className)} ref={ref} {...others}>
<div className={classes.steps}>{items}</div>
{active > filteredChildren.length - 1 && completedContent}
</Box>
)
}) as any
Stepper.Step = Step
Stepper.Completed = StepCompleted
Stepper.displayName = "@mantine/core/Stepper"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment