Skip to content

feat: RAC StepList and Wizard - #10794

Open
snowystinger wants to merge 15 commits into
mainfrom
wizard-and-stepper
Open

snowystinger wants to merge 15 commits into
mainfrom
wizard-and-stepper

Conversation

@snowystinger

Copy link
Copy Markdown
Member

Closes

From discussion, split into two components. A "readonly" StepList based on html ol and an interactive Wizard based on tabs.

✅ 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 AGENTS.md.

📝 Test Instructions:

🧢 Your Project:

@rspbot

rspbot commented Oct 16, 2026

Copy link
Copy Markdown

<InternalStepListContext.Provider value={{selectedId, groupId}}>
<dom.ol
{...mergeProps(DOMProps, renderProps)}
role="list"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are the roles needed here, aren't they implicit?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Oh, TIL Safari removes semantics when you add list-style: none https://www.havardbrynjulfsen.design/writing/a-new-way-to-remove-list-styles-in-safari

@snowystinger snowystinger Oct 17, 2026 •

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... that was a really annoying one to track down and fix, I think it's easier than the strange display empty string in that blog, though another interesting approach. Can't ensure it for RAC though since we are generally style-less


// Need this so I can get an id on the contents so that the announcement makes sense. It lands on this element, not the `li` itself.
export const StepListItemContent = /*#__PURE__*/ (forwardRef as forwardRefType)(
function StepListItemContent(props: StepListItemContentProps, ref: ForwardedRef<HTMLDivElement>) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Should this have render props?

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.

Honestly, I'm still trying to get rid of this component entirely, open to ideas. One idea I had was using Text and its context.

{...DOMProps}
ref={objRef}
id={id}
role="link" // No role === default group which means three arrow keys to get through each item

@nwidynski nwidynski Oct 18, 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.

img, perhaps with localized aria-roledescription, to avoid VO+Space announcement?

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, both of them feel like a lie :-/ but i like the idea of img, less implied interaction. I'd still like for people to be able to pass a link in here (Step by step example) and neither link nor img would allow for that

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.

tested some more with img and actual links, I think it's a better experience with img, lets users know it's not interactive and it's just representing a future step I think. I'll get our a11y team to review it as well, but tentatively moving to it now.

@rspbot

rspbot commented Oct 19, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Oct 23, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Oct 23, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Nov 6, 2026

Copy link
Copy Markdown

</span>
)}
{/* Every step has an href, but only completed steps can be navigated back to. */}
<StepListItemContent href={isCompleted ? step.href : undefined}>

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.

StepListItemLink? It can be either a link or not depending if an href is supplied

@@ -0,0 +1,255 @@
/*

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.

Which should be Stepper and which should be Wizard
See: https://reui.io/docs/components/base/stepper guidelines on when not to use to find link to wizard as well

Right now ours are inverse

/**
* A StepList displays the steps of a multi step process, and which of them the user has completed.
*/
export const StepList = /*#__PURE__*/ (forwardRef as forwardRefType)(function StepList<T>(

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.

Do we want renderProps here? what would be useful states/values/properties to send into it


const StepListItemContext = createContext<{hiddenId: string | undefined} | undefined>(undefined);

export const StepListItemContent = /*#__PURE__*/ (forwardRef as forwardRefType)(

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.

Didn't want to use Link directly because I thought that might be more confusing since an href is not required. Link would theoretically just render a span in that case.

I also wanted element structure parity between the non-link and link cases to make it easier to style

However, I need other opinions. See the stories to get an idea of how it's used with and without hrefs.

* and is never rolled back unless it's in a controlled state,
* so previously reached steps stay navigable after navigating backwards.
*/
export function useWizardState(options: WizardStateOptions): WizardState {

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.

I will move these out to react-aria, I've kept them here for ease of iteration and hopefully review

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.

follow up, should I build this based on RAC Tabs or base it instead on the react aria/stately hooks and have wizard be it's own RAC and the hooks just call the tabs hooks

@rspbot

rspbot commented Nov 10, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Nov 11, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Nov 11, 2026

Copy link
Copy Markdown

@snowystinger
snowystinger marked this pull request as ready for review November 11, 2026 02:17
@snowystinger snowystinger mentioned this pull request Nov 11, 2026
7 tasks
@snowystinger snowystinger changed the title feat: StepList round two feat: RAC StepList and Wizard Nov 11, 2026
@rspbot

rspbot commented Nov 11, 2026

Copy link
Copy Markdown

@rspbot

rspbot commented Nov 11, 2026

Copy link
Copy Markdown
## API Changes

react-aria-components

/react-aria-components:StepList

+StepList <T> {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ReactNode | (T) => ReactNode
+  className?: string = 'react-aria-StepList'
+  dependencies?: ReadonlyArray<any>
+  items?: Iterable<T>
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, undefined>
+  selectedKey: Key
+  slot?: string | null
+  style?: CSSProperties
+}

/react-aria-components:StepListContext

+StepListContext {
+  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'
+  id?: Key
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, StepListItemRenderProps>
+  style?: StyleOrFunction<StepListItemRenderProps>
+}

/react-aria-components:StepListItemContent

+StepListItemContent {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ReactNode
+  className?: string
+  download?: boolean | string
+  href?: Href
+  hrefLang?: string
+  id?: string
+  ping?: string
+  referrerPolicy?: HTMLAttributeReferrerPolicy
+  rel?: string
+  render?: (DetailedHTMLProps<LinkWithRequiredHref, HTMLAnchorElement> | React.JSX.IntrinsicElements[keyof React.JSX.IntrinsicElements], undefined) => ReactElement
+  routerOptions?: RouterOptions
+  style?: CSSProperties
+  target?: HTMLAttributeAnchorTarget
+}

/react-aria-components:Wizard

+Wizard {
+  allowsRandomAccess?: boolean
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ReactNode
+  className?: ClassNameOrFunction<TabsRenderProps> = 'react-aria-Wizard'
+  defaultLastCompletedStep?: Key
+  defaultSelectedKey?: Key
+  disabledKeys?: Iterable<Key>
+  id?: string
+  isDisabled?: boolean
+  keyboardActivation?: 'automatic' | 'manual' = 'automatic'
+  lastCompletedStep?: Key | null
+  onLastCompletedStepChange?: (Key | null) => void
+  onSelectionChange?: (Key) => void
+  orientation?: Orientation = 'horizontal'
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, TabsRenderProps>
+  selectedKey?: Key | null
+  slot?: string | null
+  style?: StyleOrFunction<TabsRenderProps>
+}

/react-aria-components:WizardStepList

+WizardStepList <T extends {}> {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ReactNode | ({}) => ReactNode
+  className?: ClassNameOrFunction<TabListRenderProps> = 'react-aria-WizardStepList'
+  dependencies?: ReadonlyArray<any>
+  items?: Iterable<{}>
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, TabListRenderProps>
+  style?: StyleOrFunction<TabListRenderProps>
+}

/react-aria-components:WizardStep

+WizardStep {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ChildrenOrFunction<T>
+  className?: ClassNameOrFunction<WizardStepRenderProps> = 'react-aria-WizardStep'
+  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], WizardStepRenderProps) => ReactElement
+  routerOptions?: RouterOptions
+  style?: StyleOrFunction<T>
+  target?: HTMLAttributeAnchorTarget
+}

/react-aria-components:WizardPanels

+WizardPanels <T extends {}> {
+  children?: ReactNode | ({}) => ReactNode
+  className?: string = 'react-aria-WizardPanels'
+  dependencies?: ReadonlyArray<any>
+  items?: Iterable<{}>
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, undefined>
+  style?: CSSProperties
+}

/react-aria-components:WizardPanel

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

/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'
+  dependencies?: ReadonlyArray<any>
+  items?: Iterable<T>
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, undefined>
+  selectedKey: Key
+  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'
+  id?: Key
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, StepListItemRenderProps>
+  style?: StyleOrFunction<StepListItemRenderProps>
+}

/react-aria-components:StepListItemRenderProps

+StepListItemRenderProps {
+  isCompleted: boolean
+  isCurrent: boolean
+  stepNumber: number
+}

/react-aria-components:StepListItemContentProps

+StepListItemContentProps {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ReactNode
+  className?: string
+  download?: boolean | string
+  href?: Href
+  hrefLang?: string
+  id?: string
+  ping?: string
+  referrerPolicy?: HTMLAttributeReferrerPolicy
+  rel?: string
+  render?: (DetailedHTMLProps<LinkWithRequiredHref, HTMLAnchorElement> | React.JSX.IntrinsicElements[keyof React.JSX.IntrinsicElements], undefined) => ReactElement
+  routerOptions?: RouterOptions
+  style?: CSSProperties
+  target?: HTMLAttributeAnchorTarget
+}

/react-aria-components:WizardProps

+WizardProps {
+  allowsRandomAccess?: boolean
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ReactNode
+  className?: ClassNameOrFunction<TabsRenderProps> = 'react-aria-Wizard'
+  defaultLastCompletedStep?: Key
+  defaultSelectedKey?: Key
+  disabledKeys?: Iterable<Key>
+  id?: string
+  isDisabled?: boolean
+  keyboardActivation?: 'automatic' | 'manual' = 'automatic'
+  lastCompletedStep?: Key | null
+  onLastCompletedStepChange?: (Key | null) => void
+  onSelectionChange?: (Key) => void
+  orientation?: Orientation = 'horizontal'
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, TabsRenderProps>
+  selectedKey?: Key | null
+  slot?: string | null
+  style?: StyleOrFunction<TabsRenderProps>
+}

/react-aria-components:WizardStepListProps

+WizardStepListProps <T> {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ReactNode | (T) => ReactNode
+  className?: ClassNameOrFunction<TabListRenderProps> = 'react-aria-WizardStepList'
+  dependencies?: ReadonlyArray<any>
+  items?: Iterable<T>
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, TabListRenderProps>
+  style?: StyleOrFunction<TabListRenderProps>
+}

/react-aria-components:WizardStepProps

+WizardStepProps {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ChildrenOrFunction<T>
+  className?: ClassNameOrFunction<WizardStepRenderProps> = 'react-aria-WizardStep'
+  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], WizardStepRenderProps) => ReactElement
+  routerOptions?: RouterOptions
+  style?: StyleOrFunction<T>
+  target?: HTMLAttributeAnchorTarget
+}

/react-aria-components:WizardStepRenderProps

+WizardStepRenderProps {
+  isCompleted: boolean
+  isCurrent: boolean
+  isDisabled: boolean
+  isFocusVisible: boolean
+  isFocused: boolean
+  isHovered: boolean
+  isPressed: boolean
+  isSelected: boolean
+  stepNumber: number
+}

/react-aria-components:WizardPanelsProps

+WizardPanelsProps <T> {
+  children?: ReactNode | (T) => ReactNode
+  className?: string = 'react-aria-WizardPanels'
+  dependencies?: ReadonlyArray<any>
+  items?: Iterable<T>
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, undefined>
+  style?: CSSProperties
+}

/react-aria-components:WizardPanelProps

+WizardPanelProps {
+  aria-describedby?: string
+  aria-details?: string
+  aria-label?: string
+  aria-labelledby?: string
+  children?: ChildrenOrFunction<TabPanelRenderProps>
+  className?: ClassNameOrFunction<TabPanelRenderProps> = 'react-aria-WizardPanel'
+  id?: Key
+  render?: DOMRenderFunction<keyof React.JSX.IntrinsicElements, TabPanelRenderProps>
+  shouldForceMount?: boolean = false
+  style?: StyleOrFunction<TabPanelRenderProps>
+}

/react-aria-components:WizardStepAria

+WizardStepAria {
+  isCompleted: boolean
+  isCurrent: boolean
+  stepNumber: number
+}

/react-aria-components:WizardState

+WizardState <T> {
+  collection: Collection<Node<T>>
+  disabledKeys: Set<Key>
+  lastCompletedStep: Key | null
+  selectedKey: Key | null
+  setSelectedKey: (Key) => void
+}

/react-aria-components:WizardStateOptions

+WizardStateOptions <T> {
+  allowsRandomAccess?: boolean
+  collection: Collection<Node<T>>
+  defaultLastCompletedStep?: Key
+  defaultSelectedKey?: Key
+  disabledKeys?: Iterable<Key>
+  lastCompletedStep?: Key | null
+  onLastCompletedStepChange?: (Key | null) => void
+  onSelectionChange?: (Key) => void
+  selectedKey?: Key | null
+}

@rspbot

rspbot commented Nov 11, 2026

Copy link
Copy Markdown

Agent Skills Changes

Added (4)
Modified (12)
Install

React Spectrum S2:

npx skills add https://d1pzu54gtk2aed.cloudfront.net/pr/7c66174166563d9ca7da37fa205a97ff9844d6af/

React Aria:

npx skills add https://d5iwopk28bdhl.cloudfront.net/pr/7c66174166563d9ca7da37fa205a97ff9844d6af/

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.

5 participants