From 90b9a276495531c3c96f820c58dd59768a6a0cb1 Mon Sep 17 00:00:00 2001 From: Jonjo McKay Date: Wed, 22 Jul 2026 15:43:24 +0100 Subject: [PATCH 1/4] [@mantine/core] Stepper: Make stepper composite --- .../mantine.dev/src/mdx/data/mdx-core-data.ts | 2 +- apps/mantine.dev/src/pages/core/stepper.mdx | 19 ++ .../core/Stepper/Stepper.demo.compound.tsx | 84 +++++ .../Stepper/Stepper.demo.compoundSticky.tsx | 160 +++++++++ .../core/Stepper/Stepper.demos.story.tsx | 10 + .../demos/src/demos/core/Stepper/index.ts | 2 + .../src/components/Stepper/Stepper.context.ts | 21 +- .../src/components/Stepper/Stepper.story.tsx | 79 ++++- .../src/components/Stepper/Stepper.test.tsx | 121 +++++++ .../core/src/components/Stepper/Stepper.tsx | 315 ++++-------------- .../StepperCompleted/StepperCompleted.tsx | 29 +- .../Stepper/StepperContent/StepperContent.tsx | 66 ++++ .../Stepper/StepperRoot/StepperRoot.tsx | 210 ++++++++++++ .../Stepper/StepperStep/StepperStep.tsx | 2 +- .../Stepper/StepperSteps/StepperSteps.tsx | 122 +++++++ .../core/src/components/Stepper/index.ts | 18 +- scripts/docgen/docgen-paths.ts | 3 + 17 files changed, 1000 insertions(+), 263 deletions(-) create mode 100644 packages/@docs/demos/src/demos/core/Stepper/Stepper.demo.compound.tsx create mode 100644 packages/@docs/demos/src/demos/core/Stepper/Stepper.demo.compoundSticky.tsx create mode 100644 packages/@mantine/core/src/components/Stepper/StepperContent/StepperContent.tsx create mode 100644 packages/@mantine/core/src/components/Stepper/StepperRoot/StepperRoot.tsx create mode 100644 packages/@mantine/core/src/components/Stepper/StepperSteps/StepperSteps.tsx diff --git a/apps/mantine.dev/src/mdx/data/mdx-core-data.ts b/apps/mantine.dev/src/mdx/data/mdx-core-data.ts index facd3c27312..45df7e566bf 100644 --- a/apps/mantine.dev/src/mdx/data/mdx-core-data.ts +++ b/apps/mantine.dev/src/mdx/data/mdx-core-data.ts @@ -1118,7 +1118,7 @@ export const MDX_CORE_DATA: Record = { title: 'Stepper', package: '@mantine/core', slug: '/core/stepper', - props: ['Stepper', 'StepperStep'], + props: ['Stepper', 'StepperStep', 'StepperRoot', 'StepperSteps', 'StepperContent'], styles: ['Stepper'], componentPrefix: 'Stepper', description: 'Display content divided into a steps sequence', diff --git a/apps/mantine.dev/src/pages/core/stepper.mdx b/apps/mantine.dev/src/pages/core/stepper.mdx index fd14699acc2..62abe47031e 100644 --- a/apps/mantine.dev/src/pages/core/stepper.mdx +++ b/apps/mantine.dev/src/pages/core/stepper.mdx @@ -8,6 +8,25 @@ export default Layout(MDX_DATA.Stepper); +## Compound components + +`Stepper` component children must follow a strict structure: `Stepper.Step` and `Stepper.Completed` +components must be direct children of the `Stepper` component. If you need full control over the layout, +use compound components instead: + +- `Stepper.Root` accepts the same props as `Stepper` and renders its children as is +- `Stepper.Steps` renders the steps list, `Stepper.Step` components must be its direct children +- `Stepper.Content` renders its children when the step with the associated 0-based `step` index is active +- `Stepper.Completed` renders its children when `active` is greater than or equal to its `step` prop; `step` is required with `Stepper.Root` and set automatically by `Stepper` + + + +Unlike the `Stepper` component, compound components do not restrict the layout structure – +you can wrap them with any elements. For example, you can pin step indicators to the top of +the screen while the content below is scrolled independently: + + + ## Allow step select To disable step selection, set the `allowStepSelect` prop on the `Stepper.Step` component. diff --git a/packages/@docs/demos/src/demos/core/Stepper/Stepper.demo.compound.tsx b/packages/@docs/demos/src/demos/core/Stepper/Stepper.demo.compound.tsx new file mode 100644 index 00000000000..16c39047d1b --- /dev/null +++ b/packages/@docs/demos/src/demos/core/Stepper/Stepper.demo.compound.tsx @@ -0,0 +1,84 @@ +import { useState } from 'react'; +import { Button, Group, Stepper } from '@mantine/core'; +import { MantineDemo } from '@mantinex/demo'; + +const code = ` +import { useState } from 'react'; +import { Stepper, Button, Group } from '@mantine/core'; + +function Demo() { + const [active, setActive] = useState(0); + const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current)); + const prevStep = () => setActive((current) => (current > 0 ? current - 1 : current)); + + return ( + <> + + + + + + + + + Step 1 content: Create an account + + + Step 2 content: Verify email + + + Step 3 content: Get full access + + + Completed, click back button to get to previous step + + + + + + + + + ); +} +`; + +function Demo() { + const [active, setActive] = useState(0); + const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current)); + const prevStep = () => setActive((current) => (current > 0 ? current - 1 : current)); + + return ( + <> + + + + + + + + Step 1 content: Create an account + Step 2 content: Verify email + Step 3 content: Get full access + + Completed, click back button to get to previous step + + + + + + + + + ); +} + +export const compound: MantineDemo = { + type: 'code', + component: Demo, + code, +}; diff --git a/packages/@docs/demos/src/demos/core/Stepper/Stepper.demo.compoundSticky.tsx b/packages/@docs/demos/src/demos/core/Stepper/Stepper.demo.compoundSticky.tsx new file mode 100644 index 00000000000..587324eed67 --- /dev/null +++ b/packages/@docs/demos/src/demos/core/Stepper/Stepper.demo.compoundSticky.tsx @@ -0,0 +1,160 @@ +import { useState } from 'react'; +import { Box, ScrollArea, Stack, Stepper, Text } from '@mantine/core'; +import { MantineDemo } from '@mantinex/demo'; + +const code = ` +import { useState } from 'react'; +import { Box, ScrollArea, Stack, Stepper, Text } from '@mantine/core'; + +function Demo() { + const [active, setActive] = useState(0); + + return ( + + + + + + + + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. + + + Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. + + + Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + + + + + + + Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium. + + + Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. + + + Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores. + + + Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit. + + + + + + + At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti. + + + Et harum quidem rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae itaque earum. + + + Rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat. + + + Sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. + + + + + + ); +} +`; + +function Demo() { + const [active, setActive] = useState(0); + + return ( + + + + + + + + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor + incididunt ut labore et dolore magna aliqua. + + + Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex + ea commodo consequat. + + + Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat + nulla pariatur. + + + Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt + mollit anim id est laborum. + + + + + + + Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque + laudantium. + + + Totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto + beatae vitae dicta sunt explicabo. + + + Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia + consequuntur magni dolores. + + + Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci + velit. + + + + + + + At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium + voluptatum deleniti atque corrupti. + + + Et harum quidem rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint + et molestiae non recusandae itaque earum. + + + Rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias + consequatur aut perferendis doloribus asperiores repellat. + + + Sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam + quaerat voluptatem. + + + + + + ); +} + +export const compoundSticky: MantineDemo = { + type: 'code', + component: Demo, + code, +}; diff --git a/packages/@docs/demos/src/demos/core/Stepper/Stepper.demos.story.tsx b/packages/@docs/demos/src/demos/core/Stepper/Stepper.demos.story.tsx index 08114ac982d..4fab46fe1fe 100644 --- a/packages/@docs/demos/src/demos/core/Stepper/Stepper.demos.story.tsx +++ b/packages/@docs/demos/src/demos/core/Stepper/Stepper.demos.story.tsx @@ -8,6 +8,16 @@ export const Demo_usage = { render: renderDemo(demos.usage), }; +export const Demo_compound = { + name: '⭐ Demo: compound', + render: renderDemo(demos.compound), +}; + +export const Demo_compoundSticky = { + name: '⭐ Demo: compoundSticky', + render: renderDemo(demos.compoundSticky), +}; + export const Demo_configurator = { name: '⭐ Demo: configurator', render: renderDemo(demos.configurator), diff --git a/packages/@docs/demos/src/demos/core/Stepper/index.ts b/packages/@docs/demos/src/demos/core/Stepper/index.ts index 45ce3a68e2b..8f56af36f73 100644 --- a/packages/@docs/demos/src/demos/core/Stepper/index.ts +++ b/packages/@docs/demos/src/demos/core/Stepper/index.ts @@ -1,4 +1,6 @@ export { usage } from './Stepper.demo.usage'; +export { compound } from './Stepper.demo.compound'; +export { compoundSticky } from './Stepper.demo.compoundSticky'; export { configurator } from './Stepper.demo.configurator'; export { icons } from './Stepper.demo.icons'; export { iconSizeConfigurator } from './Stepper.demo.iconSizeConfigurator'; diff --git a/packages/@mantine/core/src/components/Stepper/Stepper.context.ts b/packages/@mantine/core/src/components/Stepper/Stepper.context.ts index b6d09ff8bce..88d68e7dc31 100644 --- a/packages/@mantine/core/src/components/Stepper/Stepper.context.ts +++ b/packages/@mantine/core/src/components/Stepper/Stepper.context.ts @@ -1,12 +1,27 @@ -import { createSafeContext, GetStylesApi } from '../../core'; -import type { StepperFactory } from './Stepper'; +import { use } from 'react'; +import { createSafeContext, GetStylesApi, MantineColor } from '../../core'; +import type { StepFragmentComponent, StepperRootFactory } from './StepperRoot/StepperRoot'; export interface StepperContextValue { - getStyles: GetStylesApi; + getStyles: GetStylesApi; orientation: 'horizontal' | 'vertical' | undefined; iconPosition: 'left' | 'right' | undefined; + active: number; + onStepClick?: (stepIndex: number) => void; + allowNextStepsSelect: boolean | undefined; + icon: React.ReactNode | StepFragmentComponent; + completedIcon: React.ReactNode | StepFragmentComponent; + progressIcon: React.ReactNode | StepFragmentComponent; + color: MantineColor | undefined; + iconSize: number | string | undefined; + wrap: boolean | undefined; + keepMounted: boolean | undefined; } export const [StepperProvider, useStepperContext] = createSafeContext( 'Stepper component was not found in tree' ); + +export function useOptionalStepperContext() { + return use(StepperProvider); +} diff --git a/packages/@mantine/core/src/components/Stepper/Stepper.story.tsx b/packages/@mantine/core/src/components/Stepper/Stepper.story.tsx index 5f3aab66a0a..5549f7125e6 100644 --- a/packages/@mantine/core/src/components/Stepper/Stepper.story.tsx +++ b/packages/@mantine/core/src/components/Stepper/Stepper.story.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { NumberInput, Stack, Text } from '../..'; +import { Box, NumberInput, ScrollArea, Stack, Text } from '../..'; import { Button } from '../Button'; import { Group } from '../Group'; import { Stepper } from './Stepper'; @@ -202,3 +202,80 @@ export function RightIconPosition() { ); } + +export function CompoundComponents() { + const [active, setActive] = useState(0); + const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current)); + const prevStep = () => setActive((current) => (current > 0 ? current - 1 : current)); + + return ( +
+ + + + + + + + Step 1 content: Create an account + Step 2 content: Verify email + Step 3 content: Get full access + + Completed, click back button to get to previous step + + + + + + + +
+ ); +} + +export function CompoundStickySteps() { + const [active, setActive] = useState(0); + const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current)); + const prevStep = () => setActive((current) => (current > 0 ? current - 1 : current)); + + const content = (step: number) => + Array(20) + .fill(0) + .map((_, index) => ( + + Step {step + 1} scrollable content, paragraph {index + 1} + + )); + + return ( + + + + + + + + + + + + {content(0)} + {content(1)} + {content(2)} + + Completed, click back button to get to previous step + + + + + + + + + + ); +} diff --git a/packages/@mantine/core/src/components/Stepper/Stepper.test.tsx b/packages/@mantine/core/src/components/Stepper/Stepper.test.tsx index 6f296cde8ce..29ba215e65d 100644 --- a/packages/@mantine/core/src/components/Stepper/Stepper.test.tsx +++ b/packages/@mantine/core/src/components/Stepper/Stepper.test.tsx @@ -1,7 +1,12 @@ import { render, screen, tests, userEvent } from '@mantine-tests/core'; +import { renderToString } from 'react-dom/server'; +import { MantineProvider } from '../../core'; import { Stepper, StepperProps, StepperStylesNames } from './Stepper'; import { StepperCompleted } from './StepperCompleted/StepperCompleted'; +import { StepperContent } from './StepperContent/StepperContent'; +import { StepperRoot, StepperRootProps } from './StepperRoot/StepperRoot'; import { StepperStep } from './StepperStep/StepperStep'; +import { StepperSteps } from './StepperSteps/StepperSteps'; const defaultProps: StepperProps = { active: 1, @@ -132,3 +137,119 @@ describe('@mantine/core/Stepper', () => { expect(spy).toHaveBeenCalledWith(2); }); }); + +describe('@mantine/core/Stepper compound components', () => { + const getCompound = (props: Partial = {}) => ( + + + + + + + test-step-content-0 + test-step-content-1 + test-step-content-2 + test-step-completed + + ); + + tests.axe([getCompound()]); + + it('exposes Stepper.Root, Stepper.Steps and Stepper.Content components', () => { + expect(Stepper.Root).toBe(StepperRoot); + expect(Stepper.Steps).toBe(StepperSteps); + expect(Stepper.Content).toBe(StepperContent); + }); + + it('renders content of active step', () => { + const { rerender } = render(getCompound({ active: 1 })); + expect(screen.getByText('test-step-content-1')).toBeInTheDocument(); + expect(screen.queryByText('test-step-content-0')).not.toBeInTheDocument(); + expect(screen.queryByText('test-step-completed')).not.toBeInTheDocument(); + + rerender(getCompound({ active: 2 })); + expect(screen.getByText('test-step-content-2')).toBeInTheDocument(); + expect(screen.queryByText('test-step-content-1')).not.toBeInTheDocument(); + }); + + it('renders completed content when active is beyond the last step', () => { + const { rerender } = render(getCompound({ active: 3 })); + expect(screen.getByText('test-step-completed')).toBeInTheDocument(); + + rerender(getCompound({ active: 100 })); + expect(screen.getByText('test-step-completed')).toBeInTheDocument(); + + rerender(getCompound({ active: 1 })); + expect(screen.queryByText('test-step-completed')).not.toBeInTheDocument(); + }); + + it('renders completed content during server-side rendering', () => { + const html = renderToString({getCompound({ active: 3 })}); + expect(html).toContain('test-step-completed'); + }); + + it('renders nothing when Stepper.Completed is used outside Stepper', () => { + render(test-step-completed); + expect(screen.queryByText('test-step-completed')).not.toBeInTheDocument(); + }); + + it('calls onStepClick with clicked step index', async () => { + const spy = jest.fn(); + render(getCompound({ onStepClick: spy })); + await userEvent.click(screen.getAllByRole('button')[2]); + expect(spy).toHaveBeenCalledWith(2); + }); + + it('only allows selecting previous steps when allowNextStepsSelect is false', async () => { + const spy = jest.fn(); + render(getCompound({ onStepClick: spy, allowNextStepsSelect: false })); + + const stepButtons = screen.getAllByRole('button'); + + await userEvent.click(stepButtons[2]); + expect(spy).not.toHaveBeenCalled(); + + await userEvent.click(stepButtons[0]); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith(0); + }); + + it('supports Styles API selectors through Stepper.Root classNames', () => { + const { container } = render( + getCompound({ + classNames: { + steps: 'test-steps', + separator: 'test-separator', + step: 'test-step', + content: 'test-content', + }, + }) + ); + + expect(container.querySelector('.test-steps')).toBeInTheDocument(); + expect(container.querySelector('.test-separator')).toBeInTheDocument(); + expect(container.querySelector('.test-step')).toBeInTheDocument(); + expect(container.querySelector('.test-content')).toBeInTheDocument(); + }); + + it('supports steps rendered inside custom wrapper elements', () => { + render( + +
+ + + + +
+
+ test-step-content-0 + test-step-content-1 + test-step-completed +
+
+ ); + + expect(screen.getByText('test-step-completed')).toBeInTheDocument(); + expect(screen.queryByText('test-step-content-0')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/@mantine/core/src/components/Stepper/Stepper.tsx b/packages/@mantine/core/src/components/Stepper/Stepper.tsx index 391df27a68f..0a48fd0bbcf 100644 --- a/packages/@mantine/core/src/components/Stepper/Stepper.tsx +++ b/packages/@mantine/core/src/components/Stepper/Stepper.tsx @@ -1,112 +1,36 @@ -import { Activity, Children, cloneElement } from 'react'; -import { - Box, - BoxProps, - createVarsResolver, - ElementProps, - factory, - Factory, - getAutoContrastValue, - getContrastColor, - getFontSize, - getRadius, - getSize, - getSpacing, - getThemeColor, - MantineColor, - MantineRadius, - MantineSize, - MantineSpacing, - rem, - StylesApiProps, - useProps, - useStyles, -} from '../../core'; -import { StepperProvider, type StepperContextValue } from './Stepper.context'; +import { Children, cloneElement } from 'react'; +import { factory, Factory, useProps, useResolvedStylesApi } from '../../core'; +import type { StepperContextValue } from './Stepper.context'; import { StepperCompleted, StepperCompletedProps } from './StepperCompleted/StepperCompleted'; +import { + StepperContent, + type StepperContentProps, + type StepperContentStylesNames, +} from './StepperContent/StepperContent'; +import { + StepperRoot, + varsResolver, + type StepFragmentComponent, + type StepperRootCssVariables, + type StepperRootProps, + type StepperRootStylesNames, +} from './StepperRoot/StepperRoot'; import { StepperStep, StepperStepProps } from './StepperStep/StepperStep'; +import { + StepperSteps, + type StepperStepsProps, + type StepperStepsStylesNames, +} from './StepperSteps/StepperSteps'; import classes from './Stepper.module.css'; -export type StepFragmentComponent = React.FC<{ step: number }>; -export type StepperStylesNames = - | 'root' - | 'separator' - | 'steps' - | 'content' - | 'step' - | 'stepLoader' - | 'verticalSeparator' - | 'stepWrapper' - | 'stepIcon' - | 'stepCompletedIcon' - | 'stepIconContent' - | 'stepBody' - | 'stepLabel' - | 'stepDescription'; +export type { StepFragmentComponent }; -export type StepperCssVariables = { - root: - | '--stepper-color' - | '--stepper-icon-color' - | '--stepper-icon-size' - | '--stepper-content-padding' - | '--stepper-radius' - | '--stepper-fz' - | '--stepper-spacing'; -}; +export type StepperStylesNames = StepperRootStylesNames; +export type StepperCssVariables = StepperRootCssVariables; -export interface StepperProps - extends BoxProps, StylesApiProps, ElementProps<'div'> { +export interface StepperProps extends StepperRootProps { /** `Stepper.Step` components */ children: React.ReactNode; - - /** Called when a clickable step is clicked with its 0-based index. Not called for the currently active step. */ - onStepClick?: (stepIndex: number) => void; - - /** Index of the active step */ - active: number; - - /** Step icon @default step index + 1 */ - icon?: React.ReactNode | StepFragmentComponent; - - /** Step icon displayed when step is completed @default CheckIcon */ - completedIcon?: React.ReactNode | StepFragmentComponent; - - /** Step icon displayed when step is in progress @default step index + 1 */ - progressIcon?: React.ReactNode | StepFragmentComponent; - - /** Key of `theme.colors` or any valid CSS color, controls colors of active and progress steps @default theme.primaryColor */ - color?: MantineColor; - - /** Controls size of the step icon, by default icon size is inferred from `size` prop */ - iconSize?: number | string; - - /** Key of `theme.spacing` or any valid CSS value to set `padding-top` of the content @default 'md' */ - contentPadding?: MantineSpacing; - - /** Stepper orientation @default 'horizontal' */ - orientation?: 'vertical' | 'horizontal'; - - /** Icon position relative to the step body @default 'left' */ - iconPosition?: 'right' | 'left'; - - /** Controls size of various Stepper elements */ - size?: MantineSize; - - /** Key of `theme.radius` or any valid CSS value to set steps border-radius @default "xl" */ - radius?: MantineRadius; - - /** When true, users can click and jump to any step. When false, users can only navigate to completed steps @default true */ - allowNextStepsSelect?: boolean; - - /** Determines whether steps should wrap to the next line if no space is available @default true */ - wrap?: boolean; - - /** When true, automatically adjusts the icon color in completed steps to ensure sufficient contrast against the step background color */ - autoContrast?: boolean; - - /** If set, all step content is kept mounted. React 19 `Activity` is used to preserve state while content is hidden. @default false */ - keepMounted?: boolean; } export type StepperFactory = Factory<{ @@ -117,174 +41,40 @@ export type StepperFactory = Factory<{ staticComponents: { Step: typeof StepperStep; Completed: typeof StepperCompleted; + Root: typeof StepperRoot; + Steps: typeof StepperSteps; + Content: typeof StepperContent; }; }>; -const defaultProps = { - orientation: 'horizontal', - iconPosition: 'left', - allowNextStepsSelect: true, - wrap: true, -} satisfies Partial; - -const varsResolver = createVarsResolver( - (theme, { color, iconSize, size, contentPadding, radius, autoContrast }) => ({ - root: { - '--stepper-color': color ? getThemeColor(color, theme) : undefined, - '--stepper-icon-color': getAutoContrastValue(autoContrast, theme) - ? getContrastColor({ color, theme, autoContrast }) - : undefined, - '--stepper-icon-size': - iconSize === undefined ? getSize(size, 'stepper-icon-size') : rem(iconSize), - '--stepper-content-padding': getSpacing(contentPadding), - '--stepper-radius': radius === undefined ? undefined : getRadius(radius), - '--stepper-fz': getFontSize(size), - '--stepper-spacing': getSpacing(size), - }, - }) -); - export const Stepper = factory((_props) => { - const props = useProps('Stepper', defaultProps, _props); - const { - classNames, - className, - style, - styles, - unstyled, - vars, - children, - onStepClick, - active, - icon, - completedIcon, - progressIcon, - color, - iconSize, - contentPadding, - orientation, - iconPosition, - size, - radius, - allowNextStepsSelect, - wrap, - autoContrast, - keepMounted, - attributes, - ...others - } = props; + const props = useProps('Stepper', null, _props); + const { children, classNames, styles, vars, ...others } = props; - const getStyles = useStyles({ - name: 'Stepper', - classes, - props, - className, - style, + const { resolvedClassNames, resolvedStyles } = useResolvedStylesApi({ classNames, styles, - unstyled, - attributes, - vars, - varsResolver, + props, }); const convertedChildren = Children.toArray(children) as React.ReactElement[]; const _children = convertedChildren.filter( (child) => child.type !== StepperCompleted ) as React.ReactElement[]; - const completedStep = convertedChildren.find( - (item) => item.type === StepperCompleted - ) as React.ReactElement; - - const items = _children.reduce[]>( - (acc, item: React.ReactElement, index) => { - const state = - active === index ? 'stepProgress' : active > index ? 'stepCompleted' : 'stepInactive'; - - const shouldAllowSelect = () => { - if (typeof onStepClick !== 'function') { - return false; - } - - if (typeof item.props.allowStepSelect === 'boolean') { - return item.props.allowStepSelect; - } - - return state === 'stepCompleted' || allowNextStepsSelect; - }; - - const isStepSelectionEnabled = shouldAllowSelect(); - - acc.push( - cloneElement(item, { - icon: item.props.icon || icon || index + 1, - key: index, - step: index, - state, - onClick: () => isStepSelectionEnabled && onStepClick?.(index), - allowStepClick: isStepSelectionEnabled, - completedIcon: item.props.completedIcon || completedIcon, - progressIcon: item.props.progressIcon || progressIcon, - color: item.props.color || color, - iconSize, - iconPosition: item.props.iconPosition || iconPosition, - orientation, - }) - ); - - if (orientation === 'horizontal' && index !== _children.length - 1) { - acc.push( -
- ); - } - - return acc; - }, - [] - ); + const completedStep = convertedChildren.find((item) => item.type === StepperCompleted) as + | React.ReactElement + | undefined; - const stepContent = _children[active]?.props?.children; - const completedContent = completedStep?.props?.children; - const content = active > _children.length - 1 ? completedContent : stepContent; - - const contentSection = keepMounted ? ( - <> + return ( + + {_children} {_children.map((child, index) => ( - -
{child.props.children}
-
+ + {child.props.children} + ))} - {completedStep && ( - _children.length - 1 ? 'visible' : 'hidden'}> -
{completedStep.props.children}
-
- )} - - ) : ( - content &&
{content}
- ); - - return ( - - - - {items} - - {contentSection} - - + {completedStep && cloneElement(completedStep, { step: _children.length })} +
); }); @@ -293,6 +83,9 @@ Stepper.varsResolver = varsResolver; Stepper.displayName = '@mantine/core/Stepper'; Stepper.Completed = StepperCompleted; Stepper.Step = StepperStep; +Stepper.Root = StepperRoot; +Stepper.Steps = StepperSteps; +Stepper.Content = StepperContent; export namespace Stepper { export type Props = StepperProps; @@ -309,4 +102,20 @@ export namespace Stepper { export namespace Completed { export type Props = StepperCompletedProps; } + + export namespace Root { + export type Props = StepperRootProps; + export type StylesNames = StepperRootStylesNames; + export type CssVariables = StepperRootCssVariables; + } + + export namespace Steps { + export type Props = StepperStepsProps; + export type StylesNames = StepperStepsStylesNames; + } + + export namespace Content { + export type Props = StepperContentProps; + export type StylesNames = StepperContentStylesNames; + } } diff --git a/packages/@mantine/core/src/components/Stepper/StepperCompleted/StepperCompleted.tsx b/packages/@mantine/core/src/components/Stepper/StepperCompleted/StepperCompleted.tsx index ae37107362d..8ce2dc6b39a 100644 --- a/packages/@mantine/core/src/components/Stepper/StepperCompleted/StepperCompleted.tsx +++ b/packages/@mantine/core/src/components/Stepper/StepperCompleted/StepperCompleted.tsx @@ -1,7 +1,32 @@ +import { Activity } from 'react'; +import { useOptionalStepperContext } from '../Stepper.context'; + export interface StepperCompletedProps { - /** Label content */ + /** Content displayed when all steps are completed */ children: React.ReactNode; + + /** 0-based index at which completed content is displayed. Required with `Stepper.Root`, automatically set by `Stepper`. */ + step?: number; } -export const StepperCompleted: React.FC = () => null; +export const StepperCompleted: React.FC = ({ children, step }) => { + const ctx = useOptionalStepperContext(); + + if (!ctx || step === undefined) { + return null; + } + + const active = ctx.active >= step; + + if (ctx.keepMounted) { + return ( + +
{children}
+
+ ); + } + + return active ?
{children}
: null; +}; + StepperCompleted.displayName = '@mantine/core/StepperCompleted'; diff --git a/packages/@mantine/core/src/components/Stepper/StepperContent/StepperContent.tsx b/packages/@mantine/core/src/components/Stepper/StepperContent/StepperContent.tsx new file mode 100644 index 00000000000..30ac8ed24b7 --- /dev/null +++ b/packages/@mantine/core/src/components/Stepper/StepperContent/StepperContent.tsx @@ -0,0 +1,66 @@ +import { Activity } from 'react'; +import { + Box, + BoxProps, + CompoundStylesApiProps, + ElementProps, + factory, + Factory, + useProps, +} from '../../../core'; +import { useStepperContext } from '../Stepper.context'; +import classes from '../Stepper.module.css'; + +export type StepperContentStylesNames = 'content'; + +export interface StepperContentProps + extends BoxProps, CompoundStylesApiProps, ElementProps<'div'> { + /** Content displayed when the associated step is active */ + children?: React.ReactNode; + + /** 0-based index of the associated `Stepper.Step` component */ + step: number; + + /** If set, the content is kept mounted, even if `keepMounted` is not set on the parent `Stepper.Root` component */ + keepMounted?: boolean; +} + +export type StepperContentFactory = Factory<{ + props: StepperContentProps; + ref: HTMLDivElement; + stylesNames: StepperContentStylesNames; + compound: true; +}>; + +export const StepperContent = factory((_props) => { + const props = useProps('StepperContent', null, _props); + const { children, className, style, classNames, styles, mod, step, keepMounted, ...others } = + props; + + const ctx = useStepperContext(); + const active = ctx.active === step; + const shouldKeepMounted = ctx.keepMounted || keepMounted; + + if (!shouldKeepMounted && (!active || children == null)) { + return null; + } + + const content = ( + + {children} + + ); + + if (shouldKeepMounted) { + return {content}; + } + + return content; +}); + +StepperContent.classes = classes; +StepperContent.displayName = '@mantine/core/StepperContent'; diff --git a/packages/@mantine/core/src/components/Stepper/StepperRoot/StepperRoot.tsx b/packages/@mantine/core/src/components/Stepper/StepperRoot/StepperRoot.tsx new file mode 100644 index 00000000000..8a88fbeaebc --- /dev/null +++ b/packages/@mantine/core/src/components/Stepper/StepperRoot/StepperRoot.tsx @@ -0,0 +1,210 @@ +import { + Box, + BoxProps, + createVarsResolver, + ElementProps, + factory, + Factory, + getAutoContrastValue, + getContrastColor, + getFontSize, + getRadius, + getSize, + getSpacing, + getThemeColor, + MantineColor, + MantineRadius, + MantineSize, + MantineSpacing, + rem, + StylesApiProps, + useProps, + useStyles, +} from '../../../core'; +import { StepperProvider } from '../Stepper.context'; +import classes from '../Stepper.module.css'; + +export type StepFragmentComponent = React.FC<{ step: number }>; + +export type StepperRootStylesNames = + | 'root' + | 'separator' + | 'steps' + | 'content' + | 'step' + | 'stepLoader' + | 'verticalSeparator' + | 'stepWrapper' + | 'stepIcon' + | 'stepCompletedIcon' + | 'stepIconContent' + | 'stepBody' + | 'stepLabel' + | 'stepDescription'; + +export type StepperRootCssVariables = { + root: + | '--stepper-color' + | '--stepper-icon-color' + | '--stepper-icon-size' + | '--stepper-content-padding' + | '--stepper-radius' + | '--stepper-fz' + | '--stepper-spacing'; +}; + +export interface __StepperRootProps extends BoxProps, ElementProps<'div'> { + /** Called when a clickable step is clicked with its 0-based index. Not called for the currently active step. */ + onStepClick?: (stepIndex: number) => void; + + /** Index of the active step */ + active: number; + + /** Step icon @default step index + 1 */ + icon?: React.ReactNode | StepFragmentComponent; + + /** Step icon displayed when step is completed @default CheckIcon */ + completedIcon?: React.ReactNode | StepFragmentComponent; + + /** Step icon displayed when step is in progress @default step index + 1 */ + progressIcon?: React.ReactNode | StepFragmentComponent; + + /** Key of `theme.colors` or any valid CSS color, controls colors of active and progress steps @default theme.primaryColor */ + color?: MantineColor; + + /** Controls size of the step icon, by default icon size is inferred from `size` prop */ + iconSize?: number | string; + + /** Key of `theme.spacing` or any valid CSS value to set `padding-top` of the content @default 'md' */ + contentPadding?: MantineSpacing; + + /** Stepper orientation @default 'horizontal' */ + orientation?: 'vertical' | 'horizontal'; + + /** Icon position relative to the step body @default 'left' */ + iconPosition?: 'right' | 'left'; + + /** Controls size of various Stepper elements */ + size?: MantineSize; + + /** Key of `theme.radius` or any valid CSS value to set steps border-radius @default "xl" */ + radius?: MantineRadius; + + /** When true, users can click and jump to any step. When false, users can only navigate to completed steps @default true */ + allowNextStepsSelect?: boolean; + + /** Determines whether steps should wrap to the next line if no space is available @default true */ + wrap?: boolean; + + /** When true, automatically adjusts the icon color in completed steps to ensure sufficient contrast against the step background color */ + autoContrast?: boolean; + + /** If set, all step content is kept mounted. React 19 `Activity` is used to preserve state while content is hidden. @default false */ + keepMounted?: boolean; +} + +export interface StepperRootProps extends __StepperRootProps, StylesApiProps {} + +export type StepperRootFactory = Factory<{ + props: StepperRootProps; + ref: HTMLDivElement; + stylesNames: StepperRootStylesNames; + vars: StepperRootCssVariables; +}>; + +const defaultProps = { + orientation: 'horizontal', + iconPosition: 'left', + allowNextStepsSelect: true, + wrap: true, +} satisfies Partial; + +export const varsResolver = createVarsResolver( + (theme, { color, iconSize, size, contentPadding, radius, autoContrast }) => ({ + root: { + '--stepper-color': color ? getThemeColor(color, theme) : undefined, + '--stepper-icon-color': getAutoContrastValue(autoContrast, theme) + ? getContrastColor({ color, theme, autoContrast }) + : undefined, + '--stepper-icon-size': + iconSize === undefined ? getSize(size, 'stepper-icon-size') : rem(iconSize), + '--stepper-content-padding': getSpacing(contentPadding), + '--stepper-radius': radius === undefined ? undefined : getRadius(radius), + '--stepper-fz': getFontSize(size), + '--stepper-spacing': getSpacing(size), + }, + }) +); + +export const StepperRoot = factory((_props) => { + const props = useProps('StepperRoot', defaultProps, _props); + const { + classNames, + className, + style, + styles, + unstyled, + vars, + children, + onStepClick, + active, + icon, + completedIcon, + progressIcon, + color, + iconSize, + contentPadding, + orientation, + iconPosition, + size, + radius, + allowNextStepsSelect, + wrap, + autoContrast, + keepMounted, + attributes, + ...others + } = props; + + const getStyles = useStyles({ + name: 'Stepper', + classes, + props, + className, + style, + classNames, + styles, + unstyled, + attributes, + vars, + varsResolver, + }); + + return ( + + + {children} + + + ); +}); + +StepperRoot.classes = classes; +StepperRoot.varsResolver = varsResolver; +StepperRoot.displayName = '@mantine/core/StepperRoot'; diff --git a/packages/@mantine/core/src/components/Stepper/StepperStep/StepperStep.tsx b/packages/@mantine/core/src/components/Stepper/StepperStep/StepperStep.tsx index d4411c4b099..c39b6e1f8e0 100644 --- a/packages/@mantine/core/src/components/Stepper/StepperStep/StepperStep.tsx +++ b/packages/@mantine/core/src/components/Stepper/StepperStep/StepperStep.tsx @@ -13,8 +13,8 @@ import { CheckIcon } from '../../Checkbox'; import { Loader } from '../../Loader'; import { Transition } from '../../Transition'; import { UnstyledButton } from '../../UnstyledButton'; -import type { StepFragmentComponent } from '../Stepper'; import { useStepperContext } from '../Stepper.context'; +import type { StepFragmentComponent } from '../StepperRoot/StepperRoot'; import classes from '../Stepper.module.css'; const getStepFragment = ( diff --git a/packages/@mantine/core/src/components/Stepper/StepperSteps/StepperSteps.tsx b/packages/@mantine/core/src/components/Stepper/StepperSteps/StepperSteps.tsx new file mode 100644 index 00000000000..2cc77c8b699 --- /dev/null +++ b/packages/@mantine/core/src/components/Stepper/StepperSteps/StepperSteps.tsx @@ -0,0 +1,122 @@ +import { Children, cloneElement } from 'react'; +import { + Box, + BoxProps, + CompoundStylesApiProps, + ElementProps, + factory, + Factory, + useProps, +} from '../../../core'; +import { useStepperContext } from '../Stepper.context'; +import { StepperCompleted } from '../StepperCompleted/StepperCompleted'; +import type { StepperStepProps } from '../StepperStep/StepperStep'; +import classes from '../Stepper.module.css'; + +export type StepperStepsStylesNames = 'steps' | 'separator'; + +export interface StepperStepsProps + extends BoxProps, CompoundStylesApiProps, ElementProps<'div'> { + /** `Stepper.Step` components */ + children: React.ReactNode; +} + +export type StepperStepsFactory = Factory<{ + props: StepperStepsProps; + ref: HTMLDivElement; + stylesNames: StepperStepsStylesNames; + compound: true; +}>; + +export const StepperSteps = factory((_props) => { + const props = useProps('StepperSteps', null, _props); + const { children, className, style, classNames, styles, mod, ...others } = props; + + const ctx = useStepperContext(); + const stylesApiProps = { classNames, styles, props }; + + const convertedChildren = Children.toArray(children) as React.ReactElement[]; + const _children = convertedChildren.filter( + (child) => child.type !== StepperCompleted + ) as React.ReactElement[]; + + const items = _children.reduce[]>( + (acc, item: React.ReactElement, index) => { + const state = + ctx.active === index + ? 'stepProgress' + : ctx.active > index + ? 'stepCompleted' + : 'stepInactive'; + + const shouldAllowSelect = () => { + if (typeof ctx.onStepClick !== 'function') { + return false; + } + + if (ctx.active === index) { + return false; + } + + if (typeof item.props.allowStepSelect === 'boolean') { + return item.props.allowStepSelect; + } + + return state === 'stepCompleted' || ctx.allowNextStepsSelect; + }; + + const isStepSelectionEnabled = shouldAllowSelect(); + + acc.push( + cloneElement(item, { + icon: item.props.icon || ctx.icon || index + 1, + key: index, + step: index, + state, + onClick: () => isStepSelectionEnabled && ctx.onStepClick?.(index), + allowStepClick: isStepSelectionEnabled, + completedIcon: item.props.completedIcon || ctx.completedIcon, + progressIcon: item.props.progressIcon || ctx.progressIcon, + color: item.props.color || ctx.color, + iconSize: ctx.iconSize, + iconPosition: item.props.iconPosition || ctx.iconPosition, + orientation: ctx.orientation, + }) + ); + + if (ctx.orientation === 'horizontal' && index !== _children.length - 1) { + acc.push( +
+ ); + } + + return acc; + }, + [] + ); + + return ( + + {items} + + ); +}); + +StepperSteps.classes = classes; +StepperSteps.displayName = '@mantine/core/StepperSteps'; diff --git a/packages/@mantine/core/src/components/Stepper/index.ts b/packages/@mantine/core/src/components/Stepper/index.ts index 567320b8438..35feefd3dd9 100644 --- a/packages/@mantine/core/src/components/Stepper/index.ts +++ b/packages/@mantine/core/src/components/Stepper/index.ts @@ -7,12 +7,21 @@ import type { } from './Stepper'; import type { StepperContextValue } from './Stepper.context'; import type { StepperCompletedProps } from './StepperCompleted/StepperCompleted'; +import type { + StepperContentProps, + StepperContentStylesNames, +} from './StepperContent/StepperContent'; +import type { StepperRootProps } from './StepperRoot/StepperRoot'; import type { StepperStepProps } from './StepperStep/StepperStep'; +import type { StepperStepsProps, StepperStepsStylesNames } from './StepperSteps/StepperSteps'; export { Stepper } from './Stepper'; -export { StepperStep } from './StepperStep/StepperStep'; -export { StepperCompleted } from './StepperCompleted/StepperCompleted'; export { useStepperContext } from './Stepper.context'; +export { StepperCompleted } from './StepperCompleted/StepperCompleted'; +export { StepperContent } from './StepperContent/StepperContent'; +export { StepperRoot } from './StepperRoot/StepperRoot'; +export { StepperStep } from './StepperStep/StepperStep'; +export { StepperSteps } from './StepperSteps/StepperSteps'; export type { StepperProps, @@ -22,5 +31,10 @@ export type { StepFragmentComponent, StepperStepProps, StepperCompletedProps, + StepperContentProps, + StepperContentStylesNames, + StepperRootProps, + StepperStepsProps, + StepperStepsStylesNames, StepperContextValue, }; diff --git a/scripts/docgen/docgen-paths.ts b/scripts/docgen/docgen-paths.ts index 2d2002fc4c7..c8477fb0261 100644 --- a/scripts/docgen/docgen-paths.ts +++ b/scripts/docgen/docgen-paths.ts @@ -110,6 +110,9 @@ const FILES_PATHS = getPaths([ // Stepper 'packages/@mantine/core/src/components/Stepper/StepperStep/StepperStep.tsx', + 'packages/@mantine/core/src/components/Stepper/StepperRoot/StepperRoot.tsx', + 'packages/@mantine/core/src/components/Stepper/StepperSteps/StepperSteps.tsx', + 'packages/@mantine/core/src/components/Stepper/StepperContent/StepperContent.tsx', // Timeline 'packages/@mantine/core/src/components/Timeline/TimelineItem/TimelineItem.tsx', From 7c2377f04f595180e07758e9923201fdebf2b8ac Mon Sep 17 00:00:00 2001 From: Jonjo McKay Date: Wed, 22 Jul 2026 16:57:54 +0100 Subject: [PATCH 2/4] [@mantine/core] Stepper: Added default props for backwards compatibility --- .../@mantine/core/src/components/Stepper/Stepper.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/@mantine/core/src/components/Stepper/Stepper.tsx b/packages/@mantine/core/src/components/Stepper/Stepper.tsx index 0a48fd0bbcf..59205fffa6f 100644 --- a/packages/@mantine/core/src/components/Stepper/Stepper.tsx +++ b/packages/@mantine/core/src/components/Stepper/Stepper.tsx @@ -47,8 +47,15 @@ export type StepperFactory = Factory<{ }; }>; +const defaultProps = { + orientation: 'horizontal', + iconPosition: 'left', + allowNextStepsSelect: true, + wrap: true, +} satisfies Partial; + export const Stepper = factory((_props) => { - const props = useProps('Stepper', null, _props); + const props = useProps('Stepper', defaultProps, _props); const { children, classNames, styles, vars, ...others } = props; const { resolvedClassNames, resolvedStyles } = useResolvedStylesApi({ From 57ba4e9dbfd71aa12a4a738f2de42450cb83eb56 Mon Sep 17 00:00:00 2001 From: Jonjo McKay Date: Thu, 23 Jul 2026 11:01:13 +0100 Subject: [PATCH 3/4] [@mantine/core] Stepper: Added support for getting the number of steps --- apps/mantine.dev/src/pages/core/stepper.mdx | 4 + .../core/Stepper/Stepper.demo.compound.tsx | 20 ++++- .../src/components/Stepper/Stepper.context.ts | 3 + .../src/components/Stepper/Stepper.test.tsx | 87 +++++++++++++++++++ .../core/src/components/Stepper/Stepper.tsx | 10 ++- .../Stepper/StepperRoot/StepperRoot.tsx | 9 ++ .../Stepper/StepperSteps/StepperSteps.tsx | 5 ++ 7 files changed, 134 insertions(+), 4 deletions(-) diff --git a/apps/mantine.dev/src/pages/core/stepper.mdx b/apps/mantine.dev/src/pages/core/stepper.mdx index 62abe47031e..d2338132c67 100644 --- a/apps/mantine.dev/src/pages/core/stepper.mdx +++ b/apps/mantine.dev/src/pages/core/stepper.mdx @@ -21,6 +21,10 @@ use compound components instead: +`useStepperContext` hook provides the current step index and the total number of steps. The `stepsCount` +value is detected automatically from `Stepper.Steps` children in client-side applications. Set the +`stepsCount` prop on `Stepper.Root` only when server-side rendering the step count. + Unlike the `Stepper` component, compound components do not restrict the layout structure – you can wrap them with any elements. For example, you can pin step indicators to the top of the screen while the content below is scrolled independently: diff --git a/packages/@docs/demos/src/demos/core/Stepper/Stepper.demo.compound.tsx b/packages/@docs/demos/src/demos/core/Stepper/Stepper.demo.compound.tsx index 16c39047d1b..246f33c4a83 100644 --- a/packages/@docs/demos/src/demos/core/Stepper/Stepper.demo.compound.tsx +++ b/packages/@docs/demos/src/demos/core/Stepper/Stepper.demo.compound.tsx @@ -1,10 +1,15 @@ import { useState } from 'react'; -import { Button, Group, Stepper } from '@mantine/core'; +import { Button, Group, Stepper, Text, useStepperContext } from '@mantine/core'; import { MantineDemo } from '@mantinex/demo'; const code = ` import { useState } from 'react'; -import { Stepper, Button, Group } from '@mantine/core'; +import { Stepper, Button, Group, Text, useStepperContext } from '@mantine/core'; + +function StepCount() { + const { active, stepsCount } = useStepperContext(); + return Step {Math.min(active + 1, stepsCount)} of {stepsCount}; +} function Demo() { const [active, setActive] = useState(0); @@ -14,6 +19,7 @@ function Demo() { return ( <> + @@ -45,6 +51,15 @@ function Demo() { } `; +function StepCount() { + const { active, stepsCount } = useStepperContext(); + return ( + + Step {Math.min(active + 1, stepsCount)} of {stepsCount} + + ); +} + function Demo() { const [active, setActive] = useState(0); const nextStep = () => setActive((current) => (current < 3 ? current + 1 : current)); @@ -53,6 +68,7 @@ function Demo() { return ( <> + diff --git a/packages/@mantine/core/src/components/Stepper/Stepper.context.ts b/packages/@mantine/core/src/components/Stepper/Stepper.context.ts index 88d68e7dc31..fa4670a6136 100644 --- a/packages/@mantine/core/src/components/Stepper/Stepper.context.ts +++ b/packages/@mantine/core/src/components/Stepper/Stepper.context.ts @@ -7,6 +7,9 @@ export interface StepperContextValue { orientation: 'horizontal' | 'vertical' | undefined; iconPosition: 'left' | 'right' | undefined; active: number; + stepsCount: number; + /** @internal */ + setStepsCount: (count: number) => void; onStepClick?: (stepIndex: number) => void; allowNextStepsSelect: boolean | undefined; icon: React.ReactNode | StepFragmentComponent; diff --git a/packages/@mantine/core/src/components/Stepper/Stepper.test.tsx b/packages/@mantine/core/src/components/Stepper/Stepper.test.tsx index 29ba215e65d..763298ac3d6 100644 --- a/packages/@mantine/core/src/components/Stepper/Stepper.test.tsx +++ b/packages/@mantine/core/src/components/Stepper/Stepper.test.tsx @@ -7,6 +7,7 @@ import { StepperContent } from './StepperContent/StepperContent'; import { StepperRoot, StepperRootProps } from './StepperRoot/StepperRoot'; import { StepperStep } from './StepperStep/StepperStep'; import { StepperSteps } from './StepperSteps/StepperSteps'; +import { useStepperContext } from './Stepper.context'; const defaultProps: StepperProps = { active: 1, @@ -27,6 +28,11 @@ const defaultProps: StepperProps = { ], }; +function StepCount() { + const { stepsCount } = useStepperContext(); + return
{stepsCount}
; +} + describe('@mantine/core/Stepper', () => { tests.axe([]); @@ -69,6 +75,20 @@ describe('@mantine/core/Stepper', () => { expect(screen.getByText('test-step-completed')).toBeInTheDocument(); }); + it('sets stepsCount in context', () => { + render( + + + + + + Completed + + ); + + expect(screen.getByTestId('steps-count')).toHaveTextContent('2'); + }); + it('exposes Stepper.Step and Stepper.Completed components', () => { expect(Stepper.Step).toBe(StepperStep); expect(Stepper.Completed).toBe(StepperCompleted); @@ -188,6 +208,73 @@ describe('@mantine/core/Stepper compound components', () => { expect(html).toContain('test-step-completed'); }); + it('sets stepsCount in context', () => { + render( + + + + + + Completed + + + ); + + expect(screen.getByTestId('steps-count')).toHaveTextContent('2'); + }); + + it('updates stepsCount when steps change', () => { + const { rerender } = render( + + + + + + + ); + + expect(screen.getByTestId('steps-count')).toHaveTextContent('1'); + + rerender( + + + + + + + + ); + + expect(screen.getByTestId('steps-count')).toHaveTextContent('2'); + }); + + it('supports stepsCount during server-side rendering', () => { + const compoundHtml = renderToString( + + + + + + + + + + ); + const stepperHtml = renderToString( + + + + + + + + + ); + + expect(compoundHtml).toContain('>2<'); + expect(stepperHtml).toContain('>2<'); + }); + it('renders nothing when Stepper.Completed is used outside Stepper', () => { render(test-step-completed); expect(screen.queryByText('test-step-completed')).not.toBeInTheDocument(); diff --git a/packages/@mantine/core/src/components/Stepper/Stepper.tsx b/packages/@mantine/core/src/components/Stepper/Stepper.tsx index 59205fffa6f..70568e79c92 100644 --- a/packages/@mantine/core/src/components/Stepper/Stepper.tsx +++ b/packages/@mantine/core/src/components/Stepper/Stepper.tsx @@ -56,7 +56,7 @@ const defaultProps = { export const Stepper = factory((_props) => { const props = useProps('Stepper', defaultProps, _props); - const { children, classNames, styles, vars, ...others } = props; + const { children, classNames, styles, vars, stepsCount, ...others } = props; const { resolvedClassNames, resolvedStyles } = useResolvedStylesApi({ classNames, @@ -73,7 +73,13 @@ export const Stepper = factory((_props) => { | undefined; return ( - + {_children} {_children.map((child, index) => ( diff --git a/packages/@mantine/core/src/components/Stepper/StepperRoot/StepperRoot.tsx b/packages/@mantine/core/src/components/Stepper/StepperRoot/StepperRoot.tsx index 8a88fbeaebc..1be9bf48a1d 100644 --- a/packages/@mantine/core/src/components/Stepper/StepperRoot/StepperRoot.tsx +++ b/packages/@mantine/core/src/components/Stepper/StepperRoot/StepperRoot.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Box, BoxProps, @@ -60,6 +61,9 @@ export interface __StepperRootProps extends BoxProps, ElementProps<'div'> { /** Index of the active step */ active: number; + /** Number of steps, overrides the value detected from `Stepper.Steps` children. Set for correct value during server-side rendering. */ + stepsCount?: number; + /** Step icon @default step index + 1 */ icon?: React.ReactNode | StepFragmentComponent; @@ -148,6 +152,7 @@ export const StepperRoot = factory((_props) => { children, onStepClick, active, + stepsCount, icon, completedIcon, progressIcon, @@ -166,6 +171,8 @@ export const StepperRoot = factory((_props) => { ...others } = props; + const [detectedStepsCount, setStepsCount] = useState(0); + const getStyles = useStyles({ name: 'Stepper', classes, @@ -187,6 +194,8 @@ export const StepperRoot = factory((_props) => { orientation, iconPosition, active, + stepsCount: stepsCount ?? detectedStepsCount, + setStepsCount, onStepClick, allowNextStepsSelect, icon, diff --git a/packages/@mantine/core/src/components/Stepper/StepperSteps/StepperSteps.tsx b/packages/@mantine/core/src/components/Stepper/StepperSteps/StepperSteps.tsx index 2cc77c8b699..82adc142fee 100644 --- a/packages/@mantine/core/src/components/Stepper/StepperSteps/StepperSteps.tsx +++ b/packages/@mantine/core/src/components/Stepper/StepperSteps/StepperSteps.tsx @@ -1,3 +1,4 @@ +import { useIsomorphicEffect } from '@mantine/hooks'; import { Children, cloneElement } from 'react'; import { Box, @@ -40,6 +41,10 @@ export const StepperSteps = factory((_props) => { (child) => child.type !== StepperCompleted ) as React.ReactElement[]; + useIsomorphicEffect(() => { + ctx.setStepsCount(_children.length); + }, [_children.length]); + const items = _children.reduce[]>( (acc, item: React.ReactElement, index) => { const state = From cee4cd6f78e692e7fa31dd2f26e8f421d3167139 Mon Sep 17 00:00:00 2001 From: Jonjo McKay Date: Thu, 23 Jul 2026 11:11:51 +0100 Subject: [PATCH 4/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../@mantine/core/src/components/Stepper/Stepper.context.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@mantine/core/src/components/Stepper/Stepper.context.ts b/packages/@mantine/core/src/components/Stepper/Stepper.context.ts index fa4670a6136..423f7875368 100644 --- a/packages/@mantine/core/src/components/Stepper/Stepper.context.ts +++ b/packages/@mantine/core/src/components/Stepper/Stepper.context.ts @@ -12,9 +12,9 @@ export interface StepperContextValue { setStepsCount: (count: number) => void; onStepClick?: (stepIndex: number) => void; allowNextStepsSelect: boolean | undefined; - icon: React.ReactNode | StepFragmentComponent; - completedIcon: React.ReactNode | StepFragmentComponent; - progressIcon: React.ReactNode | StepFragmentComponent; + icon?: React.ReactNode | StepFragmentComponent; + completedIcon?: React.ReactNode | StepFragmentComponent; + progressIcon?: React.ReactNode | StepFragmentComponent; color: MantineColor | undefined; iconSize: number | string | undefined; wrap: boolean | undefined;