Skip to content

feat: Rac step list - #10601

Draft
snowystinger wants to merge 11 commits into
mainfrom
RAC-StepList
Draft

snowystinger wants to merge 11 commits into
mainfrom
RAC-StepList

Conversation

@snowystinger

@snowystinger snowystinger commented Sep 14, 2026

Copy link
Copy Markdown
Member

Closes RSP Component Milestones (view),

Picking up this previously spectrum alpha component. Revisiting the accessibility first, I've create two methods of implementing a steplist/wizard/stepper.

The first one is based on WAI tutorials and the work we did on SideNav/NavigationTree.

The other is based on Tabs, which is a natural component to use here because the steps usually control a panel of some variety, one at a time. The APIs and structure for Tabs already handle most of the use case, so it's less overall code.

We need to test on various devices and screen reader technologies to see which one behaves better or what we want to modify. We'll also run this past the a11y team.

RSP Component Milestones (view)
RSP Component Milestones (view)

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests).
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component).
  • Looked at the Accessibility Practices for this feature - Aria Practices
  • I understand every change in this PR and can explain why it's there.
  • If AI-assisted, I followed our AI contribution guidance and pointed my assistant at CLAUDE.md.

📝 Test Instructions:

🧢 Your Project:

@rspbot

rspbot commented Sep 14, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Sep 14, 2026

Copy link
Copy Markdown

@nwidynski nwidynski left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are too quick 😅 Not sure whether feedback is already welcome, since this is draft, but I hope it's okay! I've focused on accessibility as well for now.

className={props.className ?? 'react-aria-StepList'}>
<StepListStateContext.Provider value={state}>
<StepListContext.Provider value={props}>
<CollectionRoot collection={collection} />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should persist the current and focused key. I suppose Tabs will have to consume via context, similar to the KeyboardDelegate TODO?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sorry I'm not following, what's the concern here? virtualizer support?

@nwidynski nwidynski Sep 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes. I just wanted to highlight that we will encounter difficulty getting the current key persisted with the Tabs wrapper, which is a similar problem to the KeyboardDelegate.

Comment on lines +114 to +120
<div style={{display: 'flex', flexDirection: 'column', gap: '8px'}}>
<StepList {...args} onSelectionChange={handleSelectionChange} selectedKey={selectedKey} />
<div>
<button onClick={() => setStepNumber(Math.max(stepNumber - 1, 1))}>Prev</button>
<button onClick={() => setStepNumber(Math.min(stepNumber + 1, keys.length))}>Next</button>
</div>
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure whether we create an accessibility issue by allowing these to be rendered independently. Control elements should generally precede content in the tab sequence. Have you guys done research on this previously?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was debating putting in a next/prev button context. It might be weird if someone's next is a form submission though and needs to do some other things.

So then I was instead thinking I might just expose it via state on the context/render props. But for the list one, there's not really a good place to get them.

I'm also revisiting our API and considering if we want to make this whole thing more like Nav Tree where it must always be controlled, in which case it's a little less weird.

In answer to your top level question:
Reviewing is welcome, just keep in mind I'm focused on the structure and accessibility right now and trying to decide between a list of links vs tabs vs something i haven't thought of yet. I'm not really looking at the state machine logic or styles or any of that unless it has a direct influence on the first goal (colours excepted).

@nwidynski nwidynski Sep 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing is welcome, just keep in mind I'm focused on the structure and accessibility right now and trying to decide between a list of links vs tabs vs something i haven't thought of yet.

Yeah, I got that. Its tough. With Tabs we also get the problem of having to render a TabPanel. Tabs is also due for a rework if you followed our conversation in the Carousel RFC, so I'm not sure how much should be built on top before addressing some of that work.

I'm also revisiting our API and considering if we want to make this whole thing more like Nav Tree where it must always be controlled, in which case it's a little less weird.

I see the appeal, though I'm not sure it's that nice to leave control element placement up to user land. NavTree doesn't really see control elements associating with itself, but rather with the URL, so I don't think it was facing the same problem.

I will try to put some more thought into it as well. Perhaps its calling for two components, similar to Carousel, i.e. StepIndicator (ol & li, display only) & StepList (Tabs + control buttons)

@rspbot

rspbot commented Sep 16, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Sep 16, 2026

Copy link
Copy Markdown
## API Changes

react-aria-components

/react-aria-components:Tab

 Tab {
+  aria-current?: AriaAttributes['aria-current']
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   className?: ClassNameOrFunction<TabRenderProps> = 'react-aria-Tab'
   download?: boolean | string
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onBlur?: (FocusEvent<Target>) => void
   onClick?: (MouseEvent<FocusableElement>) => void
   onFocus?: (FocusEvent<Target>) => void
   onFocusChange?: (boolean) => void
   onHoverChange?: (boolean) => void
   onHoverEnd?: (HoverEvent) => void
   onHoverStart?: (HoverEvent) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   onPressUp?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   render?: (DetailedHTMLProps<LinkWithRequiredHref, HTMLAnchorElement> | React.JSX.IntrinsicElements[keyof React.JSX.IntrinsicElements], TabRenderProps) => ReactElement
   routerOptions?: RouterOptions
   style?: StyleOrFunction<T>
   target?: HTMLAttributeAnchorTarget
 }

/react-aria-components:TabProps

 TabProps {
+  aria-current?: AriaAttributes['aria-current']
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   className?: ClassNameOrFunction<TabRenderProps> = 'react-aria-Tab'
   download?: boolean | string
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onBlur?: (FocusEvent<Target>) => void
   onClick?: (MouseEvent<FocusableElement>) => void
   onFocus?: (FocusEvent<Target>) => void
   onFocusChange?: (boolean) => void
   onHoverChange?: (boolean) => void
   onHoverEnd?: (HoverEvent) => void
   onHoverStart?: (HoverEvent) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   onPressUp?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   render?: (DetailedHTMLProps<LinkWithRequiredHref, HTMLAnchorElement> | React.JSX.IntrinsicElements[keyof React.JSX.IntrinsicElements], TabRenderProps) => ReactElement
   routerOptions?: RouterOptions
   style?: StyleOrFunction<T>
   target?: HTMLAttributeAnchorTarget
 }

/react-aria-components:StepList

+StepList <T extends {}> {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ReactNode | (T) => ReactNode
+  className?: string = 'react-aria-StepList'
+  collection?: Collection<Node<T>>
+  defaultLastCompletedStep?: Key
+  defaultSelectedKey?: Key | null
+  dependencies?: ReadonlyArray<any>
+  disabledKeys?: Iterable<Key>
+  disallowEmptySelection?: boolean
+  id?: string
+  isDisabled?: boolean
+  isReadOnly?: boolean
+  items?: Iterable<T>
+  lastCompletedStep?: Key
+  onLastCompletedStepChange?: (Key | null) => void
+  onSelectionChange?: (Key) => void
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, undefined>
+  selectedKey?: Key | null
+  slot?: string | null
+  style?: CSSProperties
+}

/react-aria-components:StepListContext

+StepListContext {
+  UNTYPED
+}

/react-aria-components:StepListStateContext

+StepListStateContext {
+  UNTYPED
+}

/react-aria-components:StepListItem

+StepListItem {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ChildrenOrFunction<StepListItemRenderProps>
+  className?: ClassNameOrFunction<StepListItemRenderProps> = 'react-aria-StepListItem'
+  href?: string
+  id?: Key
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, StepListItemRenderProps>
+  style?: StyleOrFunction<StepListItemRenderProps>
+}

/react-aria-components:StepListProps

+StepListProps <T> {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ReactNode | (T) => ReactNode
+  className?: string = 'react-aria-StepList'
+  collection?: Collection<Node<T>>
+  defaultLastCompletedStep?: Key
+  defaultSelectedKey?: Key | null
+  dependencies?: ReadonlyArray<any>
+  disabledKeys?: Iterable<Key>
+  disallowEmptySelection?: boolean
+  id?: string
+  isDisabled?: boolean
+  isReadOnly?: boolean
+  items?: Iterable<T>
+  lastCompletedStep?: Key
+  onLastCompletedStepChange?: (Key | null) => void
+  onSelectionChange?: (Key) => void
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, undefined>
+  selectedKey?: Key | null
+  slot?: string | null
+  style?: CSSProperties
+}

/react-aria-components:StepListItemProps

+StepListItemProps {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ChildrenOrFunction<StepListItemRenderProps>
+  className?: ClassNameOrFunction<StepListItemRenderProps> = 'react-aria-StepListItem'
+  href?: string
+  id?: Key
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, StepListItemRenderProps>
+  style?: StyleOrFunction<StepListItemRenderProps>
+}

/react-aria-components:StepListItemRenderProps

+StepListItemRenderProps {
+  isCompleted: boolean
+  isCurrent: boolean
+  isDisabled: boolean
+  isFocusVisible: boolean
+  isFocused: boolean
+  isHovered: boolean
+  isPressed: boolean
+}

@react-aria/steplist

/@react-aria/steplist:AriaStepListProps

 AriaStepListProps <T> {
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
-  children: CollectionChildren<T>
+  collection?: Collection<Node<T>>
   defaultLastCompletedStep?: Key
   defaultSelectedKey?: Key | null
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   isDisabled?: boolean
   isReadOnly?: boolean
   items?: Iterable<T>
   lastCompletedStep?: Key
   onLastCompletedStepChange?: (Key | null) => void
   onSelectionChange?: (Key) => void
   selectedKey?: Key | null
 }

/@react-aria/steplist:StepListItemAria

 StepListItemAria {
+  allowsSelection: boolean
+  hasAction: boolean
+  isDisabled: boolean
+  isFocused: boolean
+  isPressed: boolean
+  isSelected: boolean
+  linkProps: AriaLinkOptions
   stepProps: HTMLAttributes<HTMLElement>
   stepStateProps?: HTMLAttributes<HTMLElement>
   stepStateText?: String
 }

@react-spectrum/s2

/@react-spectrum/s2:Tab

 Tab {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
+  aria-current?: AriaAttributes['aria-current']
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   download?: boolean | string
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onBlur?: (FocusEvent<Target>) => void
   onFocus?: (FocusEvent<Target>) => void
   onFocusChange?: (boolean) => void
   onHoverChange?: (boolean) => void
   onHoverEnd?: (HoverEvent) => void
   onHoverStart?: (HoverEvent) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   onPressUp?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   styles?: StylesProp
   target?: HTMLAttributeAnchorTarget
 }

/@react-spectrum/s2:TabProps

 TabProps {
   UNSAFE_className?: UnsafeClassName
   UNSAFE_style?: CSSProperties
+  aria-current?: AriaAttributes['aria-current']
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   download?: boolean | string
   href?: Href
   hrefLang?: string
   id?: Key
   isDisabled?: boolean
   onBlur?: (FocusEvent<Target>) => void
   onFocus?: (FocusEvent<Target>) => void
   onFocusChange?: (boolean) => void
   onHoverChange?: (boolean) => void
   onHoverEnd?: (HoverEvent) => void
   onHoverStart?: (HoverEvent) => void
   onPress?: (PressEvent) => void
   onPressChange?: (boolean) => void
   onPressEnd?: (PressEvent) => void
   onPressStart?: (PressEvent) => void
   onPressUp?: (PressEvent) => void
   ping?: string
   referrerPolicy?: HTMLAttributeReferrerPolicy
   rel?: string
   routerOptions?: RouterOptions
   styles?: StylesProp
   target?: HTMLAttributeAnchorTarget
 }

@react-spectrum/steplist

/@react-spectrum/steplist:StepList

 StepList <T extends {}> {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   alignSelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'center' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'stretch'>
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   bottom?: Responsive<DimensionValue>
-  children: CollectionChildren<{}>
+  collection?: Collection<Node<{}>>
   defaultLastCompletedStep?: Key
   defaultSelectedKey?: Key | null
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   flex?: Responsive<string | number | boolean>
   flexBasis?: Responsive<number | string>
   flexGrow?: Responsive<number>
   flexShrink?: Responsive<number>
   gridArea?: Responsive<string>
   gridColumn?: Responsive<string>
   gridColumnEnd?: Responsive<string>
   gridColumnStart?: Responsive<string>
   gridRow?: Responsive<string>
   gridRowEnd?: Responsive<string>
   gridRowStart?: Responsive<string>
   height?: Responsive<DimensionValue>
   id?: string
   isDisabled?: boolean
   isEmphasized?: boolean = false
   isHidden?: Responsive<boolean>
   isReadOnly?: boolean
   items?: Iterable<{}>
   justifySelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'center' | 'left' | 'right' | 'stretch'>
   lastCompletedStep?: Key
   left?: Responsive<DimensionValue>
   margin?: Responsive<DimensionValue>
   marginBottom?: Responsive<DimensionValue>
   marginEnd?: Responsive<DimensionValue>
   marginStart?: Responsive<DimensionValue>
   marginTop?: Responsive<DimensionValue>
   marginX?: Responsive<DimensionValue>
   marginY?: Responsive<DimensionValue>
   maxHeight?: Responsive<DimensionValue>
   maxWidth?: Responsive<DimensionValue>
   minHeight?: Responsive<DimensionValue>
   minWidth?: Responsive<DimensionValue>
   onLastCompletedStepChange?: (Key | null) => void
   onSelectionChange?: (Key) => void
   order?: Responsive<number>
   orientation?: Orientation = 'horizontal'
   position?: Responsive<'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'>
   right?: Responsive<DimensionValue>
   selectedKey?: Key | null
   size?: 'S' | 'M' | 'L' | 'XL' = 'M'
   start?: Responsive<DimensionValue>
   top?: Responsive<DimensionValue>
   width?: Responsive<DimensionValue>
   zIndex?: Responsive<number>
 }

/@react-spectrum/steplist:SpectrumStepListProps

 SpectrumStepListProps <T> {
   UNSAFE_className?: string
   UNSAFE_style?: CSSProperties
   alignSelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'center' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'stretch'>
   aria-describedby?: string
   aria-details?: string
   aria-label?: string
   aria-labelledby?: string
   bottom?: Responsive<DimensionValue>
-  children: CollectionChildren<T>
+  collection?: Collection<Node<T>>
   defaultLastCompletedStep?: Key
   defaultSelectedKey?: Key | null
   disabledKeys?: Iterable<Key>
   disallowEmptySelection?: boolean
   flex?: Responsive<string | number | boolean>
   flexBasis?: Responsive<number | string>
   flexGrow?: Responsive<number>
   flexShrink?: Responsive<number>
   gridArea?: Responsive<string>
   gridColumn?: Responsive<string>
   gridColumnEnd?: Responsive<string>
   gridColumnStart?: Responsive<string>
   gridRow?: Responsive<string>
   gridRowEnd?: Responsive<string>
   gridRowStart?: Responsive<string>
   height?: Responsive<DimensionValue>
   id?: string
   isDisabled?: boolean
   isEmphasized?: boolean = false
   isHidden?: Responsive<boolean>
   isReadOnly?: boolean
   items?: Iterable<T>
   justifySelf?: Responsive<'auto' | 'normal' | 'start' | 'end' | 'flex-start' | 'flex-end' | 'self-start' | 'self-end' | 'center' | 'left' | 'right' | 'stretch'>
   lastCompletedStep?: Key
   left?: Responsive<DimensionValue>
   margin?: Responsive<DimensionValue>
   marginBottom?: Responsive<DimensionValue>
   marginEnd?: Responsive<DimensionValue>
   marginStart?: Responsive<DimensionValue>
   marginTop?: Responsive<DimensionValue>
   marginX?: Responsive<DimensionValue>
   marginY?: Responsive<DimensionValue>
   maxHeight?: Responsive<DimensionValue>
   maxWidth?: Responsive<DimensionValue>
   minHeight?: Responsive<DimensionValue>
   minWidth?: Responsive<DimensionValue>
   onLastCompletedStepChange?: (Key | null) => void
   onSelectionChange?: (Key) => void
   order?: Responsive<number>
   orientation?: Orientation = 'horizontal'
   position?: Responsive<'static' | 'relative' | 'absolute' | 'fixed' | 'sticky'>
   right?: Responsive<DimensionValue>
   selectedKey?: Key | null
   size?: 'S' | 'M' | 'L' | 'XL' = 'M'
   start?: Responsive<DimensionValue>
   top?: Responsive<DimensionValue>
   width?: Responsive<DimensionValue>
   zIndex?: Responsive<number>
 }

@react-stately/steplist

/@react-stately/steplist:useStepListState

 useStepListState <T extends {}> {
-  props: StepListProps<T>
+  props: StepListStateOptions<T>
   returnVal: undefined
 }

/@react-stately/steplist:StepListStateOptions

+StepListStateOptions <T> {
+  collection?: Collection<Node<T>>
+  defaultLastCompletedStep?: Key
+  defaultSelectedKey?: Key | null
+  disabledKeys?: Iterable<Key>
+  disallowEmptySelection?: boolean
+  isDisabled?: boolean
+  isReadOnly?: boolean
+  items?: Iterable<T>
+  lastCompletedStep?: Key
+  onLastCompletedStepChange?: (Key | null) => void
+  onSelectionChange?: (Key) => void
+  selectedKey?: Key | null
+}

@rspbot

rspbot commented Sep 16, 2026

Copy link
Copy Markdown

Agent Skills Changes

Modified (11)
Install

React Spectrum S2:

npx skills add https://d1pzu54gtk2aed.cloudfront.net/pr/5d81bc09e6b31ae7779c4187a4f6233f30734ff6/

React Aria:

npx skills add https://d5iwopk28bdhl.cloudfront.net/pr/5d81bc09e6b31ae7779c4187a4f6233f30734ff6/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants