From 966cf77adbe614d07d70a75d072de994828812b3 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 10 Sep 2026 10:08:43 +1000 Subject: [PATCH 01/10] feat: RAC StepList --- packages/@react-stately/steplist/src/index.ts | 2 +- .../react-aria-components/exports/StepList.ts | 19 + .../react-aria-components/exports/index.ts | 2 + .../react-aria-components/intl/en-US.json | 5 +- .../react-aria-components/src/StepList.tsx | 260 ++++++++++++++ .../stories/StepList.stories.tsx | 193 ++++++++++ .../react-aria-components/stories/styles.css | 212 +++++++++++ .../test/StepList.test.tsx | 337 ++++++++++++++++++ packages/react-aria/exports/index.ts | 4 + packages/react-aria/exports/useStepList.ts | 15 + .../react-aria/src/steplist/useStepList.ts | 5 +- .../src/steplist/useStepListItem.ts | 14 +- .../private/steplist/useStepListState.ts | 1 + .../react-stately/exports/useStepListState.ts | 19 + .../src/steplist/useStepListState.ts | 16 +- 15 files changed, 1091 insertions(+), 13 deletions(-) create mode 100644 packages/react-aria-components/exports/StepList.ts create mode 100644 packages/react-aria-components/src/StepList.tsx create mode 100644 packages/react-aria-components/stories/StepList.stories.tsx create mode 100644 packages/react-aria-components/test/StepList.test.tsx create mode 100644 packages/react-aria/exports/useStepList.ts create mode 100644 packages/react-stately/exports/useStepListState.ts diff --git a/packages/@react-stately/steplist/src/index.ts b/packages/@react-stately/steplist/src/index.ts index 86e1ea128ae..9c6941315b9 100644 --- a/packages/@react-stately/steplist/src/index.ts +++ b/packages/@react-stately/steplist/src/index.ts @@ -12,4 +12,4 @@ export {useStepListState} from 'react-stately/private/steplist/useStepListState'; -export type {StepListProps, StepListState} from 'react-stately/private/steplist/useStepListState'; +export type {StepListProps, StepListStateOptions, StepListState} from 'react-stately/private/steplist/useStepListState'; diff --git a/packages/react-aria-components/exports/StepList.ts b/packages/react-aria-components/exports/StepList.ts new file mode 100644 index 00000000000..5b38245a340 --- /dev/null +++ b/packages/react-aria-components/exports/StepList.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// Mark as a client only package. This will cause a build time error if you try +// to import it from a React Server Component in a framework like Next.js. +import 'client-only'; + +export {StepList, StepListContext, StepListStateContext, StepListItem} from '../src/StepList'; +export type {StepListProps, StepListItemProps, StepListItemRenderProps} from '../src/StepList'; +export type {Key} from '@react-types/shared'; diff --git a/packages/react-aria-components/exports/index.ts b/packages/react-aria-components/exports/index.ts index cd7489f7c28..5750aef7218 100644 --- a/packages/react-aria-components/exports/index.ts +++ b/packages/react-aria-components/exports/index.ts @@ -195,6 +195,7 @@ export { SliderFillContext, SliderStateContext } from '../src/Slider'; +export {StepList, StepListContext, StepListStateContext, StepListItem} from '../src/StepList'; export {Switch, SwitchField, SwitchButton, SwitchContext, SwitchFieldContext} from '../src/Switch'; export { TableLoadMoreItem, @@ -639,6 +640,7 @@ export type {RootMenuTriggerState} from 'react-stately/useMenuTriggerState'; export type {SearchFieldState} from 'react-stately/useSearchFieldState'; export type {SelectState} from 'react-stately/useSelectState'; export type {SliderState} from 'react-stately/useSliderState'; +export type {StepListProps, StepListItemProps, StepListItemRenderProps} from '../src/StepList'; export type {TableState} from 'react-stately/useTableState'; export type {TabListState} from 'react-stately/useTabListState'; export type {TimeFieldState} from 'react-stately/useTimeFieldState'; diff --git a/packages/react-aria-components/intl/en-US.json b/packages/react-aria-components/intl/en-US.json index 52a68863139..ace67856c2d 100644 --- a/packages/react-aria-components/intl/en-US.json +++ b/packages/react-aria-components/intl/en-US.json @@ -2,5 +2,8 @@ "selectPlaceholder": "Select an item", "tableResizer": "Resizer", "dropzoneLabel": "DropZone", - "colorSwatchPicker": "Color swatches" + "colorSwatchPicker": "Color swatches", + "stepListItemCurrent": "Current", + "stepListItemCompleted": "Completed", + "stepListItemNotCompleted": "Not completed" } diff --git a/packages/react-aria-components/src/StepList.tsx b/packages/react-aria-components/src/StepList.tsx new file mode 100644 index 00000000000..c4e7ed777e0 --- /dev/null +++ b/packages/react-aria-components/src/StepList.tsx @@ -0,0 +1,260 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { + AriaLabelingProps, + forwardRefType, + GlobalDOMAttributes, + Collection as ICollection, + Key, + Node, + RefObject +} from '@react-types/shared'; +import {AriaStepListProps, useStepList, useStepListItem} from 'react-aria/useStepList'; +import { + ClassNameOrFunction, + ContextValue, + dom, + DOMRenderProps, + RenderProps, + SlotProps, + StyleProps, + useContextProps, + useRenderProps +} from './utils'; +import {Collection} from 'react-aria/Collection'; +import {CollectionBuilder, createLeafComponent} from 'react-aria/CollectionBuilder'; +import {CollectionNode} from 'react-aria/private/collections/BaseCollection'; +import {CollectionProps, CollectionRendererContext} from './Collection'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import intlMessages from '../intl/*.json'; +import {mergeProps} from 'react-aria/mergeProps'; +import React, {createContext, ForwardedRef, forwardRef, useContext} from 'react'; +import {StepListState, useStepListState} from 'react-stately/useStepListState'; +import {useFocusRing} from 'react-aria/useFocusRing'; +import {useHover} from 'react-aria/useHover'; +import {useId} from 'react-aria/useId'; +import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; +import {useNumberFormatter} from 'react-aria/useNumberFormatter'; +import {useObjectRef} from 'react-aria/useObjectRef'; +import {VisuallyHidden} from 'react-aria/VisuallyHidden'; + +export interface StepListProps + extends + Omit, 'disabledKeys'>, + Omit, 'children'>, + StyleProps, + SlotProps, + AriaLabelingProps, + DOMRenderProps<'ol', undefined>, + GlobalDOMAttributes { + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * element. + * + * @default 'react-aria-StepList' + */ + className?: string; + /** Whether the step list is disabled. Steps will not be focusable or interactive. */ + isDisabled?: boolean; + /** Whether the step list is read only. Steps will be focusable but non-interactive. */ + isReadOnly?: boolean; +} + +export const StepListContext = + createContext, HTMLOListElement>>(null); +export const StepListStateContext = createContext | null>(null); + +/** + * A StepList displays a sequence of steps that guides a user through a task, indicating + * which steps are completed, current, and upcoming. + */ +export const StepList = /*#__PURE__*/ (forwardRef as forwardRefType)(function StepList< + T extends object +>(props: StepListProps, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, StepListContext); + + return ( + }> + {collection => } + + ); +}); + +interface StepListInnerProps { + props: StepListProps; + collection: ICollection>; + listRef: ForwardedRef; +} + +function StepListInner({props, collection, listRef: ref}: StepListInnerProps) { + let {CollectionRoot} = useContext(CollectionRendererContext); + let state = useStepListState({...props, collection, children: undefined}); + let {listProps} = useStepList( + {...props, children: undefined}, + state, + ref as RefObject + ); + let DOMProps = filterDOMProps(props, {global: true, labelable: true}); + + return ( + + + + + + + + ); +} + +export interface StepListItemRenderProps { + /** + * Whether the step is the currently selected step. + * + * @selector [data-current] + */ + isCurrent: boolean; + /** + * Whether the step has been completed. + * + * @selector [data-completed] + */ + isCompleted: boolean; + /** + * Whether the step is disabled (not selectable). + * + * @selector [data-disabled] + */ + isDisabled: boolean; + /** + * Whether the step is currently hovered with a mouse. + * + * @selector [data-hovered] + */ + isHovered: boolean; + /** + * Whether the step is currently focused. + * + * @selector [data-focused] + */ + isFocused: boolean; + /** + * Whether the step is currently keyboard focused. + * + * @selector [data-focus-visible] + */ + isFocusVisible: boolean; + /** + * Whether the step is currently in a pressed state. + * + * @selector [data-pressed] + */ + isPressed: boolean; +} + +export interface StepListItemProps + extends + RenderProps, + AriaLabelingProps, + GlobalDOMAttributes { + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * element. A function may be provided to compute the class based on component state. + * + * @default 'react-aria-StepListItem' + */ + className?: ClassNameOrFunction; + /** + * A unique id for the step. + */ + id?: Key; +} + +class StepListItemNode extends CollectionNode { + static readonly type = 'item'; +} + +/** + * A StepListItem represents an individual step in a ``. + */ +export const StepListItem = /*#__PURE__*/ createLeafComponent( + StepListItemNode, + function StepListItem( + props: StepListItemProps, + forwardedRef: ForwardedRef, + node: Node + ) { + let ref = useObjectRef(forwardedRef); + let state = useContext(StepListStateContext)!; + let stringFormatter = useLocalizedStringFormatter(intlMessages, 'react-aria-components'); + let numberFormatter = useNumberFormatter(); + let {stepProps, ...states} = useStepListItem({key: node.key}, state, ref); + + let isCurrent = states.isSelected; + let isCompleted = state.isCompleted(node.key); + + let {focusProps, isFocused, isFocusVisible} = useFocusRing(); + let {hoverProps, isHovered} = useHover({ + isDisabled: states.isDisabled || isCurrent + }); + + let stepStateText: string; + if (isCurrent) { + stepStateText = stringFormatter.format('stepListItemCurrent'); + } else if (isCompleted) { + stepStateText = stringFormatter.format('stepListItemCompleted'); + } else { + stepStateText = stringFormatter.format('stepListItemNotCompleted'); + } + + let renderProps = useRenderProps({ + ...node.props, + children: node.rendered, + defaultClassName: 'react-aria-StepListItem', + values: { + isCurrent, + isCompleted, + isDisabled: states.isDisabled, + isHovered, + isFocused, + isFocusVisible, + isPressed: states.isPressed, + stepIndex: node.index ?? 0 + } + }); + + let DOMProps = filterDOMProps(props as any, {global: true, labelable: true}); + delete DOMProps.id; + + return ( + + {stepStateText} + {renderProps.children} + + ); + } +); diff --git a/packages/react-aria-components/stories/StepList.stories.tsx b/packages/react-aria-components/stories/StepList.stories.tsx new file mode 100644 index 00000000000..0c0d24ec76b --- /dev/null +++ b/packages/react-aria-components/stories/StepList.stories.tsx @@ -0,0 +1,193 @@ +/* + * Copyright 2024 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {action} from 'storybook/actions'; +import {Check, ClipboardList, GitCompareArrows, Hammer, LucideIcon, Rocket, ShieldCheck} from 'lucide-react'; +import {Meta, StoryObj} from '@storybook/react'; +import React, {useMemo, useState, useCallback} from 'react'; +import {StepList, StepListItem} from '../src/StepList'; +import {Text} from '../src/Text'; +import styles from './styles.css'; + +export default { + title: 'React Aria Components/StepList', + component: StepList, + args: { + onSelectionChange: action('onSelectionChange'), + onLastCompletedStepChange: action('onLastCompletedStepChange') + }, + argTypes: { + children: { + table: { + disable: true + } + }, + isReadOnly: { + control: 'boolean' + }, + onLastCompletedStepChange: { + table: { + disable: true + } + }, + onSelectionChange: { + table: { + disable: true + } + }, + orientation: { + control: { + type: 'inline-radio', + options: ['horizontal', 'vertical'] + } + } + } +} as Meta; + +export type StepListStory = StoryObj; + +export const StepListExample: StepListStory = { + render: (args: any) => ( + + + Home + + + React Aria + + + StepList + + + ) +}; + +interface ItemValue { + id: string; + url: string; +} +let items: Array = [ + {id: 'Home', url: '/'}, + {id: 'React Aria', url: '/react-aria'}, + {id: 'StepList', url: '/react-aria/StepList'} +]; + +function InteractiveStepList(args: any) { + const keys = useMemo(() => (args.items || []).map(o => o.id), [args.items]); + let [stepNumber, setStepNumber] = useState( + () => keys.indexOf(args.selectedKey || args.defaultSelectedKey) + 1 + ); + + const selectedKey = useMemo(() => { + return keys[stepNumber - 1]; + }, [keys, stepNumber]); + + const handleSelectionChange = useCallback( + key => { + setStepNumber(keys.indexOf(key) + 1); + args.onSelectionChange(key); + }, + [keys, args] + ); + return ( +
+ +
+ + +
+
+ ); +} + +export const DynamicStepListExample: StepListStory = { + render: (args: any) => ( + + {(item: ItemValue) => ( + + {item.id} + + )} + + ) +}; + +function InteractiveCoworkerStepList(args: any) { + const keys = useMemo(() => (args.items || []).map(o => o.id), [args.items]); + let [stepNumber, setStepNumber] = useState( + () => keys.indexOf(args.selectedKey || args.defaultSelectedKey) + 1 + ); + + const selectedKey = useMemo(() => { + return keys[stepNumber - 1]; + }, [keys, stepNumber]); + + const handleSelectionChange = useCallback( + key => { + setStepNumber(keys.indexOf(key) + 1); + args.onSelectionChange(key); + }, + [keys, args] + ); + return ( +
+
+ +
+
+ + +
+
+ ); +} + +interface CoworkerItemValue { + id: string; + title: string; + stage: number; + Icon: LucideIcon; +} + +let coworkerItems: Array = [ + {id: 'plan', title: 'Plan', stage: 1, Icon: ClipboardList}, + {id: 'build', title: 'Build', stage: 2, Icon: Hammer}, + {id: 'governance', title: 'Governance review', stage: 3, Icon: GitCompareArrows}, + {id: 'launch', title: 'Launch', stage: 4, Icon: ShieldCheck}, + {id: 'monitor', title: 'Monitor', stage: 5, Icon: Rocket} +]; + +export const CoworkerStepListExample: StepListStory = { + render: (args: any) => ( + + {(item: CoworkerItemValue) => { + let Icon = item.Icon; + return ( + + + + {item.title} + {`Stage ${item.stage}`} + + + ); + }} + + ) +}; diff --git a/packages/react-aria-components/stories/styles.css b/packages/react-aria-components/stories/styles.css index 7f8b969fa90..b83932c2483 100644 --- a/packages/react-aria-components/stories/styles.css +++ b/packages/react-aria-components/stories/styles.css @@ -479,3 +479,215 @@ scale: 0.85; } } + +:global(.react-aria-StepList) { + display: flex; + gap: 24px; + list-style: none; + margin: 0; + padding: 0; + font-family: sans-serif; + counter-reset: count; + + :global(.react-aria-StepListItem) { + display: flex; + align-items: center; + gap: 8px; + color: gray; + cursor: default; + counter-increment: count; + + &[data-completed] { + color: seagreen; + } + + &[data-current] { + color: slateblue; + font-weight: bold; + } + + &[data-disabled] { + cursor: default; + opacity: 0.5; + } + + &[data-focus-visible] { + outline: 2px solid slateblue; + outline-offset: 2px; + } + + &::before { + content: counter(count, decimal); + order: -1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 1px solid currentColor; + border-radius: 50%; + font-size: 12px; + } + } +} + +.coworker-StepList-wrapper { + position: relative; + display: flex; + width: 100%; + height: 100%; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.07); + box-sizing: border-box; + overflow: hidden; +} + +.coworker-StepList { + --coworker-notch: 16px; + --coworker-gap: 2px; + + display: flex; + flex: 1; + align-items: stretch; + list-style: none; + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: #141414; + + .coworker-StepListItem { + position: relative; + display: flex; + flex: 1; + align-items: center; + gap: 10px; + box-sizing: border-box; + /* leave room on the right for the arrow point plus the notch overlap */ + padding: 12px 16px 12px calc(16px + var(--coworker-notch)); + margin-left: calc(var(--coworker-notch) * -1 + var(--coworker-gap)); + color: #a1a1a1; + background: #1c1c1c; + cursor: pointer; + outline: none; + /* arrow: notch cut into the left edge, point extending off the right */ + clip-path: polygon( + 0 0, + calc(100% - var(--coworker-notch)) 0, + 100% 50%, + calc(100% - var(--coworker-notch)) 100%, + 0 100%, + var(--coworker-notch) 50% + ); + + &:first-child { + margin-left: 0; + padding-left: 16px; + clip-path: polygon( + 0 0, + calc(100% - var(--coworker-notch)) 0, + 100% 50%, + calc(100% - var(--coworker-notch)) 100%, + 0 100% + ); + } + + &:last-child { + padding-right: 16px; + clip-path: polygon( + 0 0, + 100% 0, + 100% 100%, + 0 100%, + var(--coworker-notch) 50% + ); + } + + &[data-hovered] { + background: #242424; + } + + &[data-current] { + color: #f5f5f5; + background: #3a3a3a; + cursor: default; + } + + &[data-completed] { + color: #f5f5f5; + } + + &[data-disabled] { + cursor: default; + color: #5c5c5c; + } + + &[data-focus-visible] { + outline: 2px solid #4a8cff; + outline-offset: -3px; + } + } + + .coworker-StepListItem-marker { + flex-shrink: 0; + display: inline-flex; + } + + .coworker-StepListItem-icon, + .coworker-StepListItem-check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + box-sizing: border-box; + } + + .coworker-StepListItem-icon { + color: inherit; + background: rgba(255, 255, 255, 0.08); + } + + [data-current] .coworker-StepListItem-icon { + background: rgba(255, 255, 255, 0.14); + color: #ffffff; + } + + /* completed steps swap the icon for a green check */ + .coworker-StepListItem-check { + display: none; + color: #ffffff; + background: #12a150; + } + + [data-completed] .coworker-StepListItem-icon { + display: none; + } + + [data-completed] .coworker-StepListItem-check { + display: inline-flex; + } + + .coworker-StepListItem-labels { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + line-height: 1.2; + } + + .coworker-StepListItem-title { + font-size: 13px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .coworker-StepListItem-stage { + font-size: 12px; + font-weight: 400; + color: #8a8a8a; + white-space: nowrap; + } +} diff --git a/packages/react-aria-components/test/StepList.test.tsx b/packages/react-aria-components/test/StepList.test.tsx new file mode 100644 index 00000000000..32bb86ade6e --- /dev/null +++ b/packages/react-aria-components/test/StepList.test.tsx @@ -0,0 +1,337 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {act} from '@testing-library/react'; +import {pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import React from 'react'; +import {StepList, StepListItem} from '../src/StepList'; +import userEvent from '@testing-library/user-event'; + +const items = [ + {id: 'step-one', value: 'Step 1'}, + {id: 'step-two', value: 'Step 2'}, + {id: 'step-three', value: 'Step 3'}, + {id: 'step-four', value: 'Step 4'} +]; + +type StepListProps = Omit; + +function renderComponent(props: StepListProps = {} as StepListProps) { + return render( + + {item => {item.value}} + + ); +} + +describe('StepList', function () { + let onSelectionChange = jest.fn(); + let user; + + beforeAll(() => { + user = userEvent.setup({delay: null, pointerMap}); + jest.useFakeTimers(); + }); + afterEach(() => { + act(() => { + jest.runAllTimers(); + }); + }); + + it('renders', function () { + const tree = renderComponent({onSelectionChange}); + const stepListItems = tree.getAllByRole('link'); + expect(stepListItems.length).toBe(4); + + const stepOne = stepListItems[0]; + expect(stepOne).toHaveAttribute('aria-current', 'step'); + expect(stepOne).toHaveAttribute('tabIndex', '0'); + expect(stepOne.firstElementChild!.textContent).not.toContain('Completed'); + expect(onSelectionChange).toHaveBeenCalled(); + expect(onSelectionChange).toHaveBeenCalledWith('step-one'); + + for (let i = 1; i < stepListItems.length; i++) { + expect(stepListItems[i]).toHaveAttribute('aria-disabled', 'true'); + expect(stepListItems[i].firstElementChild!.textContent).toContain('Not'); + expect(stepListItems[i]).not.toHaveAttribute('tabindex'); + } + + const stepList = tree.getByLabelText('steplist-test'); + expect(stepList).toHaveAttribute('id', 'steplist-id'); + }); + + it('includes step state text in the accessible name via aria-labelledby', function () { + const tree = renderComponent({ + defaultLastCompletedStep: 'step-two', + defaultSelectedKey: 'step-three', + onSelectionChange + }); + const stepListItems = tree.getAllByRole('link'); + + // Verify the step state text is included in referenced elements + let currentStep = stepListItems[2]; + let currentIds = currentStep.getAttribute('aria-labelledby')!.split(' '); + let stateEl = document.getElementById(currentIds[1]); + expect(stateEl!.textContent).toContain('Current'); + + let completedStep = stepListItems[0]; + let completedIds = completedStep.getAttribute('aria-labelledby')!.split(' '); + let completedStateEl = document.getElementById(completedIds[1]); + expect(completedStateEl!.textContent).toContain('Completed'); + + let notCompletedStep = stepListItems[3]; + let notCompletedIds = notCompletedStep.getAttribute('aria-labelledby')!.split(' '); + let notCompletedStateEl = document.getElementById(notCompletedIds[1]); + expect(notCompletedStateEl!.textContent).toContain('Not'); + }); + + it('attaches a user provided ref', function () { + const ref = React.createRef(); + const container = renderComponent({ref}); + const stepList = container.getByLabelText('steplist-test'); + + expect(ref.current).toBe(stepList); + }); + + it('allows user to click completed steps and immediate next step only', async function () { + const tree = renderComponent({ + defaultLastCompletedStep: 'step-two', + defaultSelectedKey: 'step-three', + onSelectionChange + }); + const stepList = tree.getByLabelText('steplist-test'); + const stepListItems = within(stepList).getAllByRole('link'); + + // select previously completed step + const stepOne = stepListItems[0]; + expect(stepOne).not.toHaveAttribute('aria-current'); + expect(stepOne.firstElementChild!.textContent).toContain('Completed'); + await user.click(stepOne); + expect(stepOne).toHaveAttribute('aria-current', 'step'); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenLastCalledWith('step-one'); + + // select immediate next step (step after last completed step) + const stepThree = stepListItems[2]; + expect(stepThree).not.toHaveAttribute('aria-current'); + expect(stepOne.firstElementChild!.textContent).toContain('Current'); + await user.click(stepThree); + expect(stepThree).toHaveAttribute('aria-current'); + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenLastCalledWith('step-three'); + onSelectionChange.mockReset(); + + // try to select step after immediate next step + const stepFour = stepListItems[3]; + expect(stepFour).not.toHaveAttribute('aria-current'); + await user.click(stepFour); + expect(stepFour).not.toHaveAttribute('aria-current'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('allows user to change selected step via tab key only', async function () { + const tree = renderComponent({ + defaultLastCompletedStep: 'step-two', + defaultSelectedKey: 'step-three', + onSelectionChange + }); + const stepList = tree.getByLabelText('steplist-test'); + const stepListItems = within(stepList).getAllByRole('link'); + + expect(stepListItems[2]).toHaveAttribute('aria-current', 'step'); + + await user.tab(); + expect(document.activeElement).toBe(stepListItems[0]); + await user.tab(); + expect(document.activeElement).toBe(stepListItems[1]); + await user.tab(); + expect(document.activeElement).toBe(stepListItems[2]); + + await user.tab({shift: true}); + expect(document.activeElement).toBe(stepListItems[1]); + await user.keyboard('{Enter}'); + expect(onSelectionChange).toHaveBeenCalled(); + expect(onSelectionChange).toHaveBeenCalledWith('step-two'); + expect(stepListItems[1]).toHaveAttribute('aria-current'); + onSelectionChange.mockReset(); + + await user.keyboard('{ArrowUp}'); + expect(stepListItems[1]).toHaveAttribute('aria-current'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should not allow user to click on disabled steps', async function () { + const tree = renderComponent({ + defaultLastCompletedStep: 'step-two', + defaultSelectedKey: 'step-three', + disabledKeys: ['step-one'], + onSelectionChange + }); + const stepList = tree.getByLabelText('steplist-test'); + const stepListItems = within(stepList).getAllByRole('link'); + + const stepOne = stepListItems[0]; + + await user.click(stepOne); + expect(stepOne).not.toHaveAttribute('aria-current'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should disable all steps when step list is disabled', async function () { + const tree = renderComponent({ + defaultLastCompletedStep: 'step-two', + isDisabled: true, + onSelectionChange + }); + expect(onSelectionChange).toHaveBeenLastCalledWith('step-three'); + onSelectionChange.mockReset(); + const stepList = tree.getByLabelText('steplist-test'); + const stepListItems = within(stepList).getAllByRole('link'); + + for (let stepListItem of stepListItems) { + expect(stepListItem).toHaveAttribute('aria-disabled', 'true'); + } + + const stepThree = stepListItems[2]; + expect(stepThree).toHaveAttribute('aria-current'); + + const stepTwo = stepListItems[1]; + await user.click(stepTwo); + expect(stepTwo).not.toHaveAttribute('aria-current'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + it('should not allow user to click previous steps when step list is readonly', async function () { + const tree = renderComponent({ + defaultSelectedKey: 'step-four', + defaultLastCompletedStep: 'step-three', + isReadOnly: true, + onSelectionChange + }); + const stepList = tree.getByLabelText('steplist-test'); + const stepListItems = within(stepList).getAllByRole('link'); + + for (let stepListItem of stepListItems) { + expect(stepListItem).toHaveAttribute('aria-disabled', 'true'); + } + + const stepFour = stepListItems[3]; + expect(stepFour).toHaveAttribute('aria-current'); + + const stepOne = stepListItems[0]; + await user.click(stepOne); + expect(stepOne).not.toHaveAttribute('aria-current'); + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + + // TODO address bug, I think we missed a call to onLastCompletedStepChange + it('updates the last completed step automatically (uncontrollled) when the selected step is updated', function () { + const onLastCompletedStepChange = jest.fn(); + const onSelectionChange = jest.fn(); + const {getByLabelText, rerender} = render( + + {item => {item.value}} + + ); + const stepList = getByLabelText('steplist-test'); + const stepListItems = within(stepList).getAllByRole('link'); + + expect(stepListItems[0]).toHaveAttribute('aria-current'); + expect(stepListItems[0].textContent).toContain('Current'); + expect(onLastCompletedStepChange).not.toHaveBeenCalled(); + + rerender( + + {item => {item.value}} + + ); + + expect(onLastCompletedStepChange).not.toHaveBeenCalled(); + expect(stepListItems[0].textContent).toContain('Completed'); + + rerender( + + {item => {item.value}} + + ); + + expect(onLastCompletedStepChange).toHaveBeenCalledWith('step-two'); + expect(stepListItems[1].textContent).toContain('Completed'); + }); + + it('does not update selected step when last completed step is controlled', function () { + const onLastCompletedStepChange = jest.fn(); + const onSelectionChange = jest.fn(); + const {getByLabelText, rerender} = render( + + {item => {item.value}} + + ); + expect(onLastCompletedStepChange).toHaveBeenCalledTimes(0); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenLastCalledWith('step-two'); + const stepList = getByLabelText('steplist-test'); + const stepListItems = within(stepList).getAllByRole('link'); + + rerender( + + {item => {item.value}} + + ); + + rerender( + + {item => {item.value}} + + ); + + expect(onLastCompletedStepChange).toHaveBeenCalledTimes(0); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(stepListItems[1]).toHaveAttribute('aria-current'); + expect(stepListItems[2].textContent).toContain('Completed'); + }); +}); diff --git a/packages/react-aria/exports/index.ts b/packages/react-aria/exports/index.ts index 2c8a2d20d1f..235ab0cfe07 100644 --- a/packages/react-aria/exports/index.ts +++ b/packages/react-aria/exports/index.ts @@ -132,6 +132,8 @@ export {useSeparator} from '../src/separator/useSeparator'; export {SSRProvider, useIsSSR} from '../src/ssr/SSRProvider'; export {useSlider} from '../src/slider/useSlider'; export {useSliderThumb} from '../src/slider/useSliderThumb'; +export {useStepList} from '../src/steplist/useStepList'; +export {useStepListItem} from '../src/steplist/useStepListItem'; export {useSwitch} from '../src/switch/useSwitch'; export {useTable} from '../src/table/useTable'; export {useTableCell} from '../src/table/useTableCell'; @@ -454,6 +456,8 @@ export type { SliderThumbAria, SliderThumbProps } from '../src/slider/useSliderThumb'; +export type {AriaStepListProps, StepListAria} from '../src/steplist/useStepList'; +export type {AriaStepListItemProps, StepListItemAria} from '../src/steplist/useStepListItem'; export type {AriaSwitchProps, SwitchAria, SwitchProps} from '../src/switch/useSwitch'; export type {AriaTableCellProps, TableCellAria} from '../src/table/useTableCell'; export type { diff --git a/packages/react-aria/exports/useStepList.ts b/packages/react-aria/exports/useStepList.ts new file mode 100644 index 00000000000..0890fcf28eb --- /dev/null +++ b/packages/react-aria/exports/useStepList.ts @@ -0,0 +1,15 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +export {useStepList} from '../src/steplist/useStepList'; +export {useStepListItem} from '../src/steplist/useStepListItem'; +export type {AriaStepListProps, StepListAria} from '../src/steplist/useStepList'; +export type {AriaStepListItemProps, StepListItemAria} from '../src/steplist/useStepListItem'; diff --git a/packages/react-aria/src/steplist/useStepList.ts b/packages/react-aria/src/steplist/useStepList.ts index 68e9ca4bb7c..9ba96daf5aa 100644 --- a/packages/react-aria/src/steplist/useStepList.ts +++ b/packages/react-aria/src/steplist/useStepList.ts @@ -15,11 +15,12 @@ import {filterDOMProps} from '../utils/filterDOMProps'; import {HTMLAttributes} from 'react'; import intlMessages from '../../intl/steplist/*.json'; import {mergeProps} from '../utils/mergeProps'; -import {StepListProps, StepListState} from 'react-stately/private/steplist/useStepListState'; +import {StepListState, StepListStateOptions} from 'react-stately/private/steplist/useStepListState'; import {useLocalizedStringFormatter} from '../i18n/useLocalizedStringFormatter'; import {useSelectableList} from '../selection/useSelectableList'; -export interface AriaStepListProps extends StepListProps, AriaLabelingProps, DOMProps {} +export interface AriaStepListProps + extends StepListStateOptions, AriaLabelingProps, DOMProps {} export interface StepListAria { listProps: HTMLAttributes; diff --git a/packages/react-aria/src/steplist/useStepListItem.ts b/packages/react-aria/src/steplist/useStepListItem.ts index 802fa7ff89a..83a797cd4c8 100644 --- a/packages/react-aria/src/steplist/useStepListItem.ts +++ b/packages/react-aria/src/steplist/useStepListItem.ts @@ -12,6 +12,7 @@ import {HTMLAttributes} from 'react'; import {Key, RefObject} from '@react-types/shared'; +import {SelectableItemStates} from '../selection/useSelectableItem'; import {StepListState} from 'react-stately/private/steplist/useStepListState'; import {useSelectableItem} from '../selection/useSelectableItem'; @@ -19,7 +20,7 @@ export interface AriaStepListItemProps { key: Key; } -export interface StepListItemAria { +export interface StepListItemAria extends SelectableItemStates { /** Props for the step link element. */ stepProps: HTMLAttributes; /** Props for the visually hidden element indicating the step state. */ @@ -34,26 +35,25 @@ export function useStepListItem( ref: RefObject ): StepListItemAria { const {key} = props; - let {selectionManager: manager, selectedKey} = state; + let {selectionManager: manager} = state; let isDisabled = !state.isSelectable(key); - let {itemProps} = useSelectableItem({ + let {itemProps, ...states} = useSelectableItem({ isDisabled, key, ref, selectionManager: manager }); - const isSelected = selectedKey === key; - return { stepProps: { ...itemProps, role: 'link', - 'aria-current': isSelected ? 'step' : undefined, + 'aria-current': states.isSelected ? 'step' : undefined, 'aria-disabled': isDisabled ? true : undefined, tabIndex: !isDisabled ? 0 : undefined - } + }, + ...states }; } diff --git a/packages/react-stately/exports/private/steplist/useStepListState.ts b/packages/react-stately/exports/private/steplist/useStepListState.ts index 98adcd641bc..0f4ed2512be 100644 --- a/packages/react-stately/exports/private/steplist/useStepListState.ts +++ b/packages/react-stately/exports/private/steplist/useStepListState.ts @@ -1,5 +1,6 @@ export { type StepListProps, + type StepListStateOptions, type StepListState, useStepListState } from '../../../src/steplist/useStepListState'; diff --git a/packages/react-stately/exports/useStepListState.ts b/packages/react-stately/exports/useStepListState.ts new file mode 100644 index 00000000000..60d52b57314 --- /dev/null +++ b/packages/react-stately/exports/useStepListState.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +export {useStepListState} from '../src/steplist/useStepListState'; + +export type { + StepListProps, + StepListStateOptions, + StepListState +} from '../src/steplist/useStepListState'; diff --git a/packages/react-stately/src/steplist/useStepListState.ts b/packages/react-stately/src/steplist/useStepListState.ts index 4e1cbff97a7..ddb7de1260b 100644 --- a/packages/react-stately/src/steplist/useStepListState.ts +++ b/packages/react-stately/src/steplist/useStepListState.ts @@ -10,7 +10,14 @@ * governing permissions and limitations under the License. */ -import {Collection, CollectionBase, Key, Node, SingleSelection} from '@react-types/shared'; +import { + Collection, + CollectionBase, + CollectionStateBase, + Key, + Node, + SingleSelection +} from '@react-types/shared'; import {SingleSelectListState, useSingleSelectListState} from '../list/useSingleSelectListState'; import {useCallback, useEffect, useMemo} from 'react'; import {useControlledState} from '../utils/useControlledState'; @@ -38,7 +45,12 @@ export interface StepListState extends SingleSelectListState { isSelectable(key: Key): boolean; } -export function useStepListState(props: StepListProps): StepListState { +export interface StepListStateOptions + extends Omit, 'children'>, CollectionStateBase {} + +export function useStepListState( + props: StepListStateOptions +): StepListState { let state = useSingleSelectListState({ ...props, onSelectionChange: props.onSelectionChange From c6758a83a4915c2060e972417a76b343e6c0eac2 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 10 Sep 2026 11:16:21 +1000 Subject: [PATCH 02/10] Coworker example and move to using links and routers --- .../react-aria-components/src/StepList.tsx | 20 ++- .../stories/StepList.stories.tsx | 127 ++++++++++-------- .../react-aria-components/stories/styles.css | 38 ++++-- packages/react-aria/src/link/useLink.ts | 9 +- .../src/steplist/useStepListItem.ts | 17 ++- 5 files changed, 133 insertions(+), 78 deletions(-) diff --git a/packages/react-aria-components/src/StepList.tsx b/packages/react-aria-components/src/StepList.tsx index c4e7ed777e0..0867bb52bc1 100644 --- a/packages/react-aria-components/src/StepList.tsx +++ b/packages/react-aria-components/src/StepList.tsx @@ -24,6 +24,7 @@ import { ContextValue, dom, DOMRenderProps, + Provider, RenderProps, SlotProps, StyleProps, @@ -35,15 +36,14 @@ import {CollectionBuilder, createLeafComponent} from 'react-aria/CollectionBuild import {CollectionNode} from 'react-aria/private/collections/BaseCollection'; import {CollectionProps, CollectionRendererContext} from './Collection'; import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {LinkContext} from './Link'; import intlMessages from '../intl/*.json'; import {mergeProps} from 'react-aria/mergeProps'; import React, {createContext, ForwardedRef, forwardRef, useContext} from 'react'; import {StepListState, useStepListState} from 'react-stately/useStepListState'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; -import {useId} from 'react-aria/useId'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; -import {useNumberFormatter} from 'react-aria/useNumberFormatter'; import {useObjectRef} from 'react-aria/useObjectRef'; import {VisuallyHidden} from 'react-aria/VisuallyHidden'; @@ -183,6 +183,10 @@ export interface StepListItemProps * A unique id for the step. */ id?: Key; + /** + * The href for the step link. + */ + href?: string; } class StepListItemNode extends CollectionNode { @@ -195,15 +199,14 @@ class StepListItemNode extends CollectionNode { export const StepListItem = /*#__PURE__*/ createLeafComponent( StepListItemNode, function StepListItem( - props: StepListItemProps, + props: StepListItemProps, // TODO fix inheritance of props forwardedRef: ForwardedRef, node: Node ) { let ref = useObjectRef(forwardedRef); let state = useContext(StepListStateContext)!; let stringFormatter = useLocalizedStringFormatter(intlMessages, 'react-aria-components'); - let numberFormatter = useNumberFormatter(); - let {stepProps, ...states} = useStepListItem({key: node.key}, state, ref); + let {stepProps, linkProps, ...states} = useStepListItem({key: node.key}, state, ref); let isCurrent = states.isSelected; let isCompleted = state.isCompleted(node.key); @@ -238,6 +241,11 @@ export const StepListItem = /*#__PURE__*/ createLeafComponent( } }); + let linkContextValue = { + ...linkProps, + href: state.isSelectable(node.key) && !isCurrent ? props.href : undefined + }; + let DOMProps = filterDOMProps(props as any, {global: true, labelable: true}); delete DOMProps.id; @@ -253,7 +261,7 @@ export const StepListItem = /*#__PURE__*/ createLeafComponent( data-focus-visible={isFocusVisible || undefined} data-pressed={states.isPressed || undefined}> {stepStateText} - {renderProps.children} + {renderProps.children} ); } diff --git a/packages/react-aria-components/stories/StepList.stories.tsx b/packages/react-aria-components/stories/StepList.stories.tsx index 0c0d24ec76b..456cb4878d2 100644 --- a/packages/react-aria-components/stories/StepList.stories.tsx +++ b/packages/react-aria-components/stories/StepList.stories.tsx @@ -11,11 +11,22 @@ */ import {action} from 'storybook/actions'; -import {Check, ClipboardList, GitCompareArrows, Hammer, LucideIcon, Rocket, ShieldCheck} from 'lucide-react'; +import { + Check, + ClipboardList, + GitCompareArrows, + Hammer, + LucideIcon, + Rocket, + ShieldCheck +} from 'lucide-react'; import {Meta, StoryObj} from '@storybook/react'; import React, {useMemo, useState, useCallback} from 'react'; import {StepList, StepListItem} from '../src/StepList'; import {Text} from '../src/Text'; +import {Link} from '../src/Link'; +// Reuse the same fake documentation router that NavigationTree.mdx uses. +import {Router, Link as RouterLink} from '../../dev/s2-docs/pages/react-aria/Router'; import styles from './styles.css'; export default { @@ -121,73 +132,81 @@ export const DynamicStepListExample: StepListStory = { ) }; -function InteractiveCoworkerStepList(args: any) { - const keys = useMemo(() => (args.items || []).map(o => o.id), [args.items]); - let [stepNumber, setStepNumber] = useState( - () => keys.indexOf(args.selectedKey || args.defaultSelectedKey) + 1 - ); - - const selectedKey = useMemo(() => { - return keys[stepNumber - 1]; - }, [keys, stepNumber]); - - const handleSelectionChange = useCallback( - key => { - setStepNumber(keys.indexOf(key) + 1); - args.onSelectionChange(key); - }, - [keys, args] - ); - return ( -
-
- -
-
- - -
-
- ); -} - interface CoworkerItemValue { id: string; + url: string; title: string; stage: number; Icon: LucideIcon; } let coworkerItems: Array = [ - {id: 'plan', title: 'Plan', stage: 1, Icon: ClipboardList}, - {id: 'build', title: 'Build', stage: 2, Icon: Hammer}, - {id: 'governance', title: 'Governance review', stage: 3, Icon: GitCompareArrows}, - {id: 'launch', title: 'Launch', stage: 4, Icon: ShieldCheck}, - {id: 'monitor', title: 'Monitor', stage: 5, Icon: Rocket} + {id: 'plan', url: '/plan', title: 'Plan', stage: 1, Icon: ClipboardList}, + {id: 'build', url: '/build', title: 'Build', stage: 2, Icon: Hammer}, + {id: 'governance', url: '/governance', title: 'Governance review', stage: 3, Icon: GitCompareArrows}, + {id: 'launch', url: '/launch', title: 'Launch', stage: 4, Icon: ShieldCheck}, + {id: 'monitor', url: '/monitor', title: 'Monitor', stage: 5, Icon: Rocket} ]; +// Translate the router's selected route into the id of the matching step so it can +// drive the StepList's `selectedKey`. +function routeToId(route: string): string { + return coworkerItems.find(item => item.url === route)?.id ?? coworkerItems[0].id; +} + export const CoworkerStepListExample: StepListStory = { render: (args: any) => ( - - {(item: CoworkerItemValue) => { - let Icon = item.Icon; + + {({selectedRoute}) => { + let currentIndex = coworkerItems.findIndex(item => item.url === selectedRoute); + let prevItem = coworkerItems[Math.max(currentIndex - 1, 0)]; + let nextItem = coworkerItems[Math.min(currentIndex + 1, coworkerItems.length - 1)]; return ( - - - - {item.title} - {`Stage ${item.stage}`} - - +
+
+ + {(item: CoworkerItemValue) => { + let Icon = item.Icon; + return ( + + }> + + + {item.title} + {`Stage ${item.stage}`} + + + + ); + }} + +
+ {/* Upcoming steps aren't selectable via click, so forward navigation goes through + the router as well — updating the route drives the controlled selectedKey. */} +
+ Prev + Next +
+
); }} -
+ ) }; diff --git a/packages/react-aria-components/stories/styles.css b/packages/react-aria-components/stories/styles.css index b83932c2483..4d927f9cb20 100644 --- a/packages/react-aria-components/stories/styles.css +++ b/packages/react-aria-components/stories/styles.css @@ -559,16 +559,11 @@ position: relative; display: flex; flex: 1; - align-items: center; - gap: 10px; box-sizing: border-box; - /* leave room on the right for the arrow point plus the notch overlap */ - padding: 12px 16px 12px calc(16px + var(--coworker-notch)); margin-left: calc(var(--coworker-notch) * -1 + var(--coworker-gap)); color: #a1a1a1; background: #1c1c1c; cursor: pointer; - outline: none; /* arrow: notch cut into the left edge, point extending off the right */ clip-path: polygon( 0 0, @@ -581,7 +576,6 @@ &:first-child { margin-left: 0; - padding-left: 16px; clip-path: polygon( 0 0, calc(100% - var(--coworker-notch)) 0, @@ -592,7 +586,6 @@ } &:last-child { - padding-right: 16px; clip-path: polygon( 0 0, 100% 0, @@ -620,11 +613,34 @@ cursor: default; color: #5c5c5c; } + } - &[data-focus-visible] { - outline: 2px solid #4a8cff; - outline-offset: -3px; - } + /* the Link fills the item so the whole chevron is the click/focus target */ + .coworker-StepListItem-link { + display: flex; + flex: 1; + align-items: center; + gap: 10px; + box-sizing: border-box; + /* leave room on the right for the arrow point plus the notch overlap */ + padding: 12px 16px 12px calc(16px + var(--coworker-notch)); + color: inherit; + text-decoration: none; + outline: none; + cursor: inherit; + } + + .coworker-StepListItem:first-child .coworker-StepListItem-link { + padding-left: 16px; + } + + .coworker-StepListItem:last-child .coworker-StepListItem-link { + padding-right: 16px; + } + + .coworker-StepListItem-link[data-focus-visible] { + outline: 2px solid #4a8cff; + outline-offset: -3px; } .coworker-StepListItem-marker { diff --git a/packages/react-aria/src/link/useLink.ts b/packages/react-aria/src/link/useLink.ts index fc2ad403476..18a5ee8069d 100644 --- a/packages/react-aria/src/link/useLink.ts +++ b/packages/react-aria/src/link/useLink.ts @@ -22,13 +22,18 @@ import { import {filterDOMProps} from '../utils/filterDOMProps'; import {handleLinkClick, useLinkProps, useRouter} from '../utils/openLink'; import {mergeProps} from '../utils/mergeProps'; -import React from 'react'; +import React, {AnchorHTMLAttributes} from 'react'; import {useFocusable} from '../interactions/useFocusable'; import {usePress} from '../interactions/usePress'; export interface LinkProps extends PressEvents, FocusableProps {} -export interface AriaLinkProps extends LinkProps, LinkDOMProps, AriaLabelingProps {} +export interface AriaLinkProps + extends + LinkProps, + LinkDOMProps, + AriaLabelingProps, + Pick, 'aria-current'> {} export interface AriaLinkOptions extends AriaLinkProps { /** Whether the link is disabled. */ diff --git a/packages/react-aria/src/steplist/useStepListItem.ts b/packages/react-aria/src/steplist/useStepListItem.ts index 83a797cd4c8..4414dfcdf78 100644 --- a/packages/react-aria/src/steplist/useStepListItem.ts +++ b/packages/react-aria/src/steplist/useStepListItem.ts @@ -10,7 +10,8 @@ * governing permissions and limitations under the License. */ -import {HTMLAttributes} from 'react'; +import {AriaLinkOptions} from 'react-aria/useLink'; +import {HTMLAttributes, useId} from 'react'; import {Key, RefObject} from '@react-types/shared'; import {SelectableItemStates} from '../selection/useSelectableItem'; import {StepListState} from 'react-stately/private/steplist/useStepListState'; @@ -27,6 +28,8 @@ export interface StepListItemAria extends SelectableItemStates { stepStateProps?: HTMLAttributes; /** Text content for the visually hidden message indicating the status of the step state. */ stepStateText?: String; + /** Props for the step link element. */ + linkProps: AriaLinkOptions; } export function useStepListItem( @@ -38,6 +41,7 @@ export function useStepListItem( let {selectionManager: manager} = state; let isDisabled = !state.isSelectable(key); + let stepId = useId(); let {itemProps, ...states} = useSelectableItem({ isDisabled, @@ -48,11 +52,14 @@ export function useStepListItem( return { stepProps: { + id: stepId, ...itemProps, - role: 'link', - 'aria-current': states.isSelected ? 'step' : undefined, - 'aria-disabled': isDisabled ? true : undefined, - tabIndex: !isDisabled ? 0 : undefined + tabIndex: undefined + }, + linkProps: { + 'aria-labelledby': stepId, + isDisabled, + 'aria-current': states.isSelected ? 'step' : undefined }, ...states }; From eb1149e4133fc87baefdc1c967ae859b351e9aa5 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 10 Sep 2026 11:16:35 +1000 Subject: [PATCH 03/10] fix lint --- .../stories/StepList.stories.tsx | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/react-aria-components/stories/StepList.stories.tsx b/packages/react-aria-components/stories/StepList.stories.tsx index 456cb4878d2..d60249179d2 100644 --- a/packages/react-aria-components/stories/StepList.stories.tsx +++ b/packages/react-aria-components/stories/StepList.stories.tsx @@ -143,7 +143,13 @@ interface CoworkerItemValue { let coworkerItems: Array = [ {id: 'plan', url: '/plan', title: 'Plan', stage: 1, Icon: ClipboardList}, {id: 'build', url: '/build', title: 'Build', stage: 2, Icon: Hammer}, - {id: 'governance', url: '/governance', title: 'Governance review', stage: 3, Icon: GitCompareArrows}, + { + id: 'governance', + url: '/governance', + title: 'Governance review', + stage: 3, + Icon: GitCompareArrows + }, {id: 'launch', url: '/launch', title: 'Launch', stage: 4, Icon: ShieldCheck}, {id: 'monitor', url: '/monitor', title: 'Monitor', stage: 5, Icon: Rocket} ]; @@ -188,9 +194,13 @@ export const CoworkerStepListExample: StepListStory = { - {item.title} + + {item.title} + {`Stage ${item.stage}`} + className={ + styles['coworker-StepListItem-stage'] + }>{`Stage ${item.stage}`} @@ -208,5 +218,13 @@ export const CoworkerStepListExample: StepListStory = { ); }} - ) + ), + parameters: { + description: + 'A StepList component for navigating through steps, based on NavigationTree with links. Follows https://www.w3.org/WAI/tutorials/forms/multi-page/#using-step-by-step-indicator mostly.' + } +}; + +export const TabBasedStepList: StepListStory = { + render: args =>
}; From 5b081a3dcb2f9b2cb0d3d7fb8e59d404e531607f Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Thu, 10 Sep 2026 14:05:31 +1000 Subject: [PATCH 04/10] Add an implementation based on tabs --- .../src/StepListTabs.tsx | 188 ++++++++++++++++++ packages/react-aria-components/src/Tabs.tsx | 8 + packages/react-aria-components/src/useStep.ts | 46 +++++ .../src/useStepListTabsState.ts | 116 +++++++++++ .../stories/StepList.stories.tsx | 178 ++++++++++++++++- .../react-aria-components/stories/styles.css | 180 +++++++++++++++++ 6 files changed, 711 insertions(+), 5 deletions(-) create mode 100644 packages/react-aria-components/src/StepListTabs.tsx create mode 100644 packages/react-aria-components/src/useStep.ts create mode 100644 packages/react-aria-components/src/useStepListTabsState.ts diff --git a/packages/react-aria-components/src/StepListTabs.tsx b/packages/react-aria-components/src/StepListTabs.tsx new file mode 100644 index 00000000000..9826e914118 --- /dev/null +++ b/packages/react-aria-components/src/StepListTabs.tsx @@ -0,0 +1,188 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import {CollectionBuilder} from 'react-aria/CollectionBuilder'; +import {forwardRefType} from '@react-types/shared'; +import {Collection as ICollection, Key, Node} from '@react-types/shared'; +import intlMessages from '../intl/*.json'; +import React, {ForwardedRef, forwardRef, ReactElement, ReactNode, useMemo} from 'react'; +import { + Tab, + TabList, + TabListProps, + TabPanel, + TabPanelProps, + TabPanels, + TabPanelsProps, + TabProps, + Tabs, + TabsProps +} from './Tabs'; +import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; +import {useStep} from './useStep'; +import {useStepListTabsState} from './useStepListTabsState'; +import {VisuallyHidden} from 'react-aria/VisuallyHidden'; + +export interface StepListTabsProps extends Omit { + /** The children of the component (a `StepListTabList` and optional `StepPanels`). */ + children?: ReactNode; + /** The currently selected step (controlled). */ + selectedKey?: Key | null; + /** The initially selected step (uncontrolled). */ + defaultSelectedKey?: Key; + /** Handler called when the selected step changes. */ + onSelectionChange?: (key: Key) => void; + /** + * The key of the last completed step (controlled). Every step up to and including the one after + * it is navigable. + */ + lastCompletedStep?: Key; + /** The key of the initially last completed step (uncontrolled). */ + defaultLastCompletedStep?: Key; + /** Handler called when the last completed step changes. */ + onLastCompletedStepChange?: (key: Key | null) => void; + /** Whether the step list is read only. Steps are displayed but not navigable. */ + isReadOnly?: boolean; +} + +/** + * A StepListTabs displays a sequence of steps built on Tabs. Previous (completed) steps and + * the current step are navigable; upcoming steps are not. The current step is marked with + * `aria-current="step"`. + */ +export const StepListTabs = /*#__PURE__*/ forwardRef(function StepListTabs( + props: StepListTabsProps, + ref: ForwardedRef +) { + let {children} = props; + return ( + + {collection => } + + ); +}); + +interface StepListTabsInnerProps { + props: StepListTabsProps; + collection: ICollection>; + tabsRef: ForwardedRef; +} + +function StepListTabsInner({props, collection, tabsRef: ref}: StepListTabsInnerProps) { + let { + children, + selectedKey, + defaultSelectedKey, + onSelectionChange, + lastCompletedStep, + defaultLastCompletedStep, + onLastCompletedStepChange, + isReadOnly = false, + ...tabsProps + } = props; + + // TODO: revist + // Don't have access to the keyboard delegate here, so just rely on the collection order. + let keys = useMemo(() => [...collection].map(item => item.key), [collection]); + + let state = useStepListTabsState({ + keys, + selectedKey, + defaultSelectedKey, + onSelectionChange, + lastCompletedStep, + defaultLastCompletedStep, + onLastCompletedStepChange, + isReadOnly + }); + + return ( + + {children} + + ); +} + +export interface StepListTabListProps extends TabListProps {} + +export const StepListTabList = /*#__PURE__*/ (forwardRef as forwardRefType)( + function StepListTabList( + props: StepListTabListProps, + ref: ForwardedRef + ) { + return ; + } +); + +export interface StepProps extends Omit {} + +function StepState({stepKey, children}: {stepKey: Key | undefined; children: ReactNode}) { + let {isCurrent, isCompleted} = useStep(stepKey); + let stringFormatter = useLocalizedStringFormatter(intlMessages, 'react-aria-components'); + + let stateText = isCurrent + ? stringFormatter.format('stepListItemCurrent') + : isCompleted + ? stringFormatter.format('stepListItemCompleted') + : stringFormatter.format('stepListItemNotCompleted'); + + return ( + + {stateText} + {children} + + ); +} + +/** A single step within a `StepListTabList` (wraps `Tab`, adds `aria-current="step"`). */ +export const Step = /*#__PURE__*/ forwardRef(function Step( + props: StepProps, + ref: ForwardedRef +) { + let {children, ...otherProps} = props; + return ( + + {renderProps => ( + + {typeof children === 'function' ? children(renderProps) : children} + + )} + + ); +}) as (props: StepProps & {ref?: ForwardedRef}) => ReactElement; + +export interface StepPanelsProps extends TabPanelsProps {} + +/** Container for the step panels within a `StepListTabs` (wraps `TabPanels`). */ +export const StepPanels = /*#__PURE__*/ (forwardRef as forwardRefType)(function StepPanels< + T extends object +>(props: StepPanelsProps, ref: ForwardedRef) { + return ; +}); + +export interface StepPanelProps extends TabPanelProps {} + +/** The content region for a single step (wraps `TabPanel`). */ +export const StepPanel = /*#__PURE__*/ forwardRef(function StepPanel( + props: StepPanelProps, + ref: ForwardedRef +) { + return ; +}); diff --git a/packages/react-aria-components/src/Tabs.tsx b/packages/react-aria-components/src/Tabs.tsx index d2932620df5..17c01ada5d4 100644 --- a/packages/react-aria-components/src/Tabs.tsx +++ b/packages/react-aria-components/src/Tabs.tsx @@ -59,6 +59,7 @@ import {inertValue} from 'react-aria/private/utils/inertValue'; import {mergeProps} from 'react-aria/mergeProps'; import {Orientation} from '@react-types/shared'; import React, { + AriaAttributes, createContext, ForwardedRef, forwardRef, @@ -150,6 +151,12 @@ export interface TabProps id?: Key; /** Whether the tab is disabled. */ isDisabled?: boolean; + /** + * The `aria-current` value applied to the tab while it is selected (the current item + * within a set), e.g. `aria-current="step"` when used as a step in a step list. Applied + * only to the selected tab, and omitted otherwise. + */ + 'aria-current'?: AriaAttributes['aria-current']; } export interface TabRenderProps { @@ -434,6 +441,7 @@ export const Tab = /*#__PURE__*/ createLeafComponent( void; + /** The key of the last completed step (controlled). */ + lastCompletedStep?: Key; + /** The key of the initially last completed step (uncontrolled). */ + defaultLastCompletedStep?: Key; + /** Handler called when the last completed step changes. */ + onLastCompletedStepChange?: (key: Key | null) => void; + /** Whether the step list is read only. */ + isReadOnly?: boolean; +} + +export interface StepListTabsState { + /** The current (selected) step. */ + selectedKey: Key | null; + /** Sets the current (selected) step. */ + setSelectedKey(key: Key): void; + /** + * The key of the last completed step. Every step up to and including the one after it is + * navigable. + */ + lastCompletedStep: Key | null; + /** + * Keys that are not navigable: steps beyond the last completed step, or every step but the + * current one when read only. + */ + disabledKeys: Set; +} + +/** + * Provides selection state for a `StepListTabs`. Single-selection and the "always one + * selected" guarantee are delegated to the underlying `Tabs`/`useTabListState`; this hook + * controls the selected step and tracks the last completed step so it can derive which steps + * are navigable. Completed steps and the current step are navigable; upcoming steps are not. + * The last completed step advances as the selection moves forward and is never rolled back, so + * previously reached steps stay navigable after navigating backwards. + */ +export function useStepListTabsState(options: StepListTabsStateOptions): StepListTabsState { + let {keys, isReadOnly = false, onSelectionChange} = options; + let firstKey = keys.length > 0 ? keys[0] : null; + + let [selectedKeyState, setSelectedKey] = useControlledState( + options.selectedKey, + options.defaultSelectedKey ?? null, + onSelectionChange + ? key => { + if (key != null) { + onSelectionChange(key); + } + } + : undefined + ); + + let selectedKey = selectedKeyState ?? firstKey; + let selectedIndex = selectedKey != null ? keys.indexOf(selectedKey) : -1; + + let [lastCompletedStep, setLastCompletedStep] = useControlledState( + options.lastCompletedStep, + options.defaultLastCompletedStep ?? null, + options.onLastCompletedStepChange + ); + let lastCompletedIndex = lastCompletedStep != null ? keys.indexOf(lastCompletedStep) : -1; + + useEffect(() => { + if (selectedIndex > lastCompletedIndex + 1 && selectedIndex > 0) { + setLastCompletedStep(keys[selectedIndex - 1]); + } + }, [keys, selectedIndex, lastCompletedIndex, setLastCompletedStep]); + + let disabledKeys = useMemo(() => { + let set = new Set(); + keys.forEach((key, i) => { + if (key === selectedKey) { + return; + } + if (isReadOnly) { + set.add(key); + return; + } + // Completed steps and the current step are navigable; upcoming steps are not. + if (i > lastCompletedIndex + 1) { + set.add(key); + } + }); + return set; + }, [keys, selectedKey, lastCompletedIndex, isReadOnly]); + + return { + selectedKey, + setSelectedKey, + lastCompletedStep, + disabledKeys + }; +} diff --git a/packages/react-aria-components/stories/StepList.stories.tsx b/packages/react-aria-components/stories/StepList.stories.tsx index d60249179d2..94548e08253 100644 --- a/packages/react-aria-components/stories/StepList.stories.tsx +++ b/packages/react-aria-components/stories/StepList.stories.tsx @@ -20,14 +20,16 @@ import { Rocket, ShieldCheck } from 'lucide-react'; +import {Link} from '../src/Link'; import {Meta, StoryObj} from '@storybook/react'; -import React, {useMemo, useState, useCallback} from 'react'; +import React, {useCallback, useMemo, useState} from 'react'; +import {Router, Link as RouterLink} from '../../dev/s2-docs/pages/react-aria/Router'; +import {RouterProvider} from 'react-aria/private/utils/openLink'; +import {Step, StepListTabList, StepListTabs, StepPanel, StepPanels} from '../src/StepListTabs'; import {StepList, StepListItem} from '../src/StepList'; -import {Text} from '../src/Text'; -import {Link} from '../src/Link'; // Reuse the same fake documentation router that NavigationTree.mdx uses. -import {Router, Link as RouterLink} from '../../dev/s2-docs/pages/react-aria/Router'; import styles from './styles.css'; +import {Text} from '../src/Text'; export default { title: 'React Aria Components/StepList', @@ -225,6 +227,172 @@ export const CoworkerStepListExample: StepListStory = { } }; +interface TabStepItem { + id: string; + title: string; + stage: number; + Icon: LucideIcon; +} + +let tabStepItems: Array = [ + {id: 'plan', title: 'Plan', stage: 1, Icon: ClipboardList}, + {id: 'build', title: 'Build', stage: 2, Icon: Hammer}, + {id: 'governance', title: 'Governance review', stage: 3, Icon: GitCompareArrows}, + {id: 'launch', title: 'Launch', stage: 4, Icon: ShieldCheck}, + {id: 'monitor', title: 'Monitor', stage: 5, Icon: Rocket} +]; + +// The chevron marker/labels for a single step. Completed steps swap the icon for a check +// via the [data-completed] hook that StepListTabs sets on each step's content. +function TabStepContent({item}: {item: TabStepItem}) { + let Icon = item.Icon; + return ( + <> + + + {item.title} + {`Stage ${item.stage}`} + + + ); +} + +// Static children variant. export const TabBasedStepList: StepListStory = { - render: args =>
+ render: (args: any) => ( +
+ +
+ + {tabStepItems.map(item => ( + + + + ))} + +
+ + {tabStepItems.map(item => ( + + {`${item.title} — stage ${item.stage} content`} + + ))} + +
+
+ ) +}; + +// Dynamic items variant. Upcoming steps aren't clickable, so forward navigation is driven +// externally via the controlled selectedKey (Prev/Next), mirroring the other StepList stories. +function InteractiveStepListTabs(args: any) { + const keys = useMemo(() => tabStepItems.map(o => o.id), []); + let [stepNumber, setStepNumber] = useState( + () => Math.max(keys.indexOf(args.defaultSelectedKey), 0) + 1 + ); + const selectedKey = keys[stepNumber - 1]; + + const handleSelectionChange = useCallback( + (key: string) => { + setStepNumber(keys.indexOf(key) + 1); + args.onSelectionChange?.(key); + }, + [keys, args] + ); + + return ( +
+ +
+ + {(item: TabStepItem) => ( + + + + )} + +
+ + {(item: TabStepItem) => ( + + {`${item.title} — stage ${item.stage} content`} + + )} + +
+
+ + +
+
+ ); +} + +export const DynamicTabBasedStepList: StepListStory = { + render: (args: any) => +}; + +// Router-driven variant: each step is a link (href) +interface RouterStepItem extends TabStepItem { + url: string; +} + +let routerStepItems: Array = tabStepItems.map(item => ({ + ...item, + url: `/${item.id}` +})); + +function RouterStepListTabs(args: any) { + let [url, setUrl] = useState('/governance'); + let currentIndex = routerStepItems.findIndex(item => item.url === url); + let prevUrl = routerStepItems[Math.max(currentIndex - 1, 0)].url; + let nextUrl = routerStepItems[Math.min(currentIndex + 1, routerStepItems.length - 1)].url; + + return ( + +
+ +
+ + {(item: RouterStepItem) => ( + + + + )} + +
+ + {(item: RouterStepItem) => ( + + {`${item.title} — stage ${item.stage} content`} + + )} + +
+
+ Prev + Next +
+
+
+ ); +} + +export const RouterTabBasedStepList: StepListStory = { + render: (args: any) => }; diff --git a/packages/react-aria-components/stories/styles.css b/packages/react-aria-components/stories/styles.css index 4d927f9cb20..ae9e3514731 100644 --- a/packages/react-aria-components/stories/styles.css +++ b/packages/react-aria-components/stories/styles.css @@ -707,3 +707,183 @@ white-space: nowrap; } } + +/* Tab-based StepList (StepListTabs) — mirrors the coworker chevron look, but the rail is a + role="tablist" of Tabs. Current = the selected tab (data-selected on the Tab root); + completed/current also land on the StepState span (display:contents) that wraps each + step's content, so descendant markers/labels can react to them. */ +.coworker-StepListTabs { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +.coworker-StepListTabList-wrapper { + position: relative; + display: flex; + width: 100%; + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.07); + box-sizing: border-box; + overflow: hidden; +} + +.coworker-StepListTabList { + --coworker-notch: 16px; + --coworker-gap: 2px; + + display: flex; + flex: 1; + align-items: stretch; + background: #141414; + + :global(.react-aria-Tab) { + position: relative; + display: flex; + flex: 1; + align-items: center; + gap: 10px; + box-sizing: border-box; + margin-left: calc(var(--coworker-notch) * -1 + var(--coworker-gap)); + padding: 12px 16px 12px calc(16px + var(--coworker-notch)); + color: #a1a1a1; + background: #1c1c1c; + cursor: pointer; + outline: none; + /* arrow: notch cut into the left edge, point extending off the right */ + clip-path: polygon( + 0 0, + calc(100% - var(--coworker-notch)) 0, + 100% 50%, + calc(100% - var(--coworker-notch)) 100%, + 0 100%, + var(--coworker-notch) 50% + ); + + &:first-child { + margin-left: 0; + padding-left: 16px; + clip-path: polygon( + 0 0, + calc(100% - var(--coworker-notch)) 0, + 100% 50%, + calc(100% - var(--coworker-notch)) 100%, + 0 100% + ); + } + + &:last-child { + padding-right: 16px; + clip-path: polygon( + 0 0, + 100% 0, + 100% 100%, + 0 100%, + var(--coworker-notch) 50% + ); + } + + &[data-hovered] { + background: #242424; + } + + /* current step = the selected tab */ + &[data-selected] { + color: #f5f5f5; + background: #3a3a3a; + cursor: default; + } + + /* upcoming steps aren't navigable */ + &[data-disabled] { + cursor: default; + color: #5c5c5c; + } + + &[data-focus-visible] { + outline: 2px solid #4a8cff; + outline-offset: -3px; + } + } + + /* completed steps get brighter labels (data-completed is on the StepState span) */ + [data-completed] .coworker-StepListTab-title { + color: #f5f5f5; + } + + .coworker-StepListTab-marker { + flex-shrink: 0; + display: inline-flex; + } + + .coworker-StepListTab-icon, + .coworker-StepListTab-check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + box-sizing: border-box; + } + + .coworker-StepListTab-icon { + color: inherit; + background: rgba(255, 255, 255, 0.08); + } + + [data-current] .coworker-StepListTab-icon { + background: rgba(255, 255, 255, 0.14); + color: #ffffff; + } + + /* completed steps swap the icon for a green check */ + .coworker-StepListTab-check { + display: none; + color: #ffffff; + background: #12a150; + } + + [data-completed] .coworker-StepListTab-icon { + display: none; + } + + [data-completed] .coworker-StepListTab-check { + display: inline-flex; + } + + .coworker-StepListTab-labels { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + line-height: 1.2; + } + + .coworker-StepListTab-title { + font-size: 13px; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .coworker-StepListTab-stage { + font-size: 12px; + font-weight: 400; + color: #8a8a8a; + white-space: nowrap; + } +} + +.coworker-StepListTabPanel { + padding: 16px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.07); + background: #141414; + color: #f5f5f5; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-size: 13px; +} From a9221eb0878e7ddc17b0b7147b9ea93a12b6e66d Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 14 Sep 2026 09:37:46 +1000 Subject: [PATCH 05/10] fix tests --- packages/@react-stately/steplist/src/index.ts | 6 +- .../react-aria-components/src/StepList.tsx | 2 +- .../react-aria-components/stories/styles.css | 16 +- .../test/StepList.test.tsx | 169 ++++++++++++------ .../src/steplist/useStepListItem.ts | 6 +- 5 files changed, 125 insertions(+), 74 deletions(-) diff --git a/packages/@react-stately/steplist/src/index.ts b/packages/@react-stately/steplist/src/index.ts index 9c6941315b9..49b0487dae1 100644 --- a/packages/@react-stately/steplist/src/index.ts +++ b/packages/@react-stately/steplist/src/index.ts @@ -12,4 +12,8 @@ export {useStepListState} from 'react-stately/private/steplist/useStepListState'; -export type {StepListProps, StepListStateOptions, StepListState} from 'react-stately/private/steplist/useStepListState'; +export type { + StepListProps, + StepListStateOptions, + StepListState +} from 'react-stately/private/steplist/useStepListState'; diff --git a/packages/react-aria-components/src/StepList.tsx b/packages/react-aria-components/src/StepList.tsx index 0867bb52bc1..a5dc693faf8 100644 --- a/packages/react-aria-components/src/StepList.tsx +++ b/packages/react-aria-components/src/StepList.tsx @@ -36,8 +36,8 @@ import {CollectionBuilder, createLeafComponent} from 'react-aria/CollectionBuild import {CollectionNode} from 'react-aria/private/collections/BaseCollection'; import {CollectionProps, CollectionRendererContext} from './Collection'; import {filterDOMProps} from 'react-aria/filterDOMProps'; -import {LinkContext} from './Link'; import intlMessages from '../intl/*.json'; +import {LinkContext} from './Link'; import {mergeProps} from 'react-aria/mergeProps'; import React, {createContext, ForwardedRef, forwardRef, useContext} from 'react'; import {StepListState, useStepListState} from 'react-stately/useStepListState'; diff --git a/packages/react-aria-components/stories/styles.css b/packages/react-aria-components/stories/styles.css index ae9e3514731..1b4b377c30b 100644 --- a/packages/react-aria-components/stories/styles.css +++ b/packages/react-aria-components/stories/styles.css @@ -586,13 +586,7 @@ } &:last-child { - clip-path: polygon( - 0 0, - 100% 0, - 100% 100%, - 0 100%, - var(--coworker-notch) 50% - ); + clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, var(--coworker-notch) 50%); } &[data-hovered] { @@ -776,13 +770,7 @@ &:last-child { padding-right: 16px; - clip-path: polygon( - 0 0, - 100% 0, - 100% 100%, - 0 100%, - var(--coworker-notch) 50% - ); + clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, var(--coworker-notch) 50%); } &[data-hovered] { diff --git a/packages/react-aria-components/test/StepList.test.tsx b/packages/react-aria-components/test/StepList.test.tsx index 32bb86ade6e..99a23cb5b68 100644 --- a/packages/react-aria-components/test/StepList.test.tsx +++ b/packages/react-aria-components/test/StepList.test.tsx @@ -11,12 +11,19 @@ */ import {act} from '@testing-library/react'; +import {Link} from '../src/Link'; import {pointerMap, render, within} from '@react-spectrum/test-utils-internal'; import React from 'react'; +import {RouterProvider} from 'react-aria/private/utils/openLink'; import {StepList, StepListItem} from '../src/StepList'; import userEvent from '@testing-library/user-event'; -const items = [ +interface StepItem { + id: string; + value: string; +} + +const items: Array = [ {id: 'step-one', value: 'Step 1'}, {id: 'step-two', value: 'Step 2'}, {id: 'step-three', value: 'Step 3'}, @@ -28,11 +35,43 @@ type StepListProps = Omit; function renderComponent(props: StepListProps = {} as StepListProps) { return render( - {item => {item.value}} + {(item: StepItem) => ( + + {item.value} + + )} ); } +function renderWithRouter(props: any = {}) { + let {defaultSelectedKey, onSelectionChange, ...rest} = props; + function Wrapper() { + let [selectedKey, setSelectedKey] = React.useState(defaultSelectedKey); + let navigate = (href: string) => { + setSelectedKey(href); + onSelectionChange?.(href); + }; + return ( + + + {(item: StepItem) => ( + + {item.value} + + )} + + + ); + } + return render(); +} + describe('StepList', function () { let onSelectionChange = jest.fn(); let user; @@ -55,13 +94,13 @@ describe('StepList', function () { const stepOne = stepListItems[0]; expect(stepOne).toHaveAttribute('aria-current', 'step'); expect(stepOne).toHaveAttribute('tabIndex', '0'); - expect(stepOne.firstElementChild!.textContent).not.toContain('Completed'); + expect(stepOne.closest('li')!.textContent).not.toContain('Completed'); expect(onSelectionChange).toHaveBeenCalled(); expect(onSelectionChange).toHaveBeenCalledWith('step-one'); for (let i = 1; i < stepListItems.length; i++) { expect(stepListItems[i]).toHaveAttribute('aria-disabled', 'true'); - expect(stepListItems[i].firstElementChild!.textContent).toContain('Not'); + expect(stepListItems[i].closest('li')!.textContent).toContain('Not'); expect(stepListItems[i]).not.toHaveAttribute('tabindex'); } @@ -77,20 +116,18 @@ describe('StepList', function () { }); const stepListItems = tree.getAllByRole('link'); - // Verify the step state text is included in referenced elements + // Each step link is labelled by its containing item, which includes the visually hidden + // step state text, so the state is part of the link's accessible name. let currentStep = stepListItems[2]; - let currentIds = currentStep.getAttribute('aria-labelledby')!.split(' '); - let stateEl = document.getElementById(currentIds[1]); + let stateEl = document.getElementById(currentStep.getAttribute('aria-labelledby')!); expect(stateEl!.textContent).toContain('Current'); let completedStep = stepListItems[0]; - let completedIds = completedStep.getAttribute('aria-labelledby')!.split(' '); - let completedStateEl = document.getElementById(completedIds[1]); + let completedStateEl = document.getElementById(completedStep.getAttribute('aria-labelledby')!); expect(completedStateEl!.textContent).toContain('Completed'); let notCompletedStep = stepListItems[3]; - let notCompletedIds = notCompletedStep.getAttribute('aria-labelledby')!.split(' '); - let notCompletedStateEl = document.getElementById(notCompletedIds[1]); + let notCompletedStateEl = document.getElementById(notCompletedStep.getAttribute('aria-labelledby')!); expect(notCompletedStateEl!.textContent).toContain('Not'); }); @@ -102,75 +139,74 @@ describe('StepList', function () { expect(ref.current).toBe(stepList); }); - it('allows user to click completed steps and immediate next step only', async function () { - const tree = renderComponent({ + it('allows user to navigate to completed steps and the immediate next step only', async function () { + const tree = renderWithRouter({ defaultLastCompletedStep: 'step-two', defaultSelectedKey: 'step-three', onSelectionChange }); const stepList = tree.getByLabelText('steplist-test'); - const stepListItems = within(stepList).getAllByRole('link'); - - // select previously completed step - const stepOne = stepListItems[0]; - expect(stepOne).not.toHaveAttribute('aria-current'); - expect(stepOne.firstElementChild!.textContent).toContain('Completed'); - await user.click(stepOne); - expect(stepOne).toHaveAttribute('aria-current', 'step'); + // Steps switch between and as selection changes, so re-query after each nav. + const getSteps = () => within(stepList).getAllByRole('link'); + + // navigate to a previously completed step + expect(getSteps()[0]).not.toHaveAttribute('aria-current'); + expect(getSteps()[0].closest('li')!.textContent).toContain('Completed'); + await user.click(getSteps()[0]); + expect(getSteps()[0]).toHaveAttribute('aria-current', 'step'); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(onSelectionChange).toHaveBeenLastCalledWith('step-one'); - // select immediate next step (step after last completed step) - const stepThree = stepListItems[2]; - expect(stepThree).not.toHaveAttribute('aria-current'); - expect(stepOne.firstElementChild!.textContent).toContain('Current'); - await user.click(stepThree); - expect(stepThree).toHaveAttribute('aria-current'); + // navigate to the immediate next step (step after last completed step) + expect(getSteps()[2]).not.toHaveAttribute('aria-current'); + await user.click(getSteps()[2]); + expect(getSteps()[2]).toHaveAttribute('aria-current'); expect(onSelectionChange).toHaveBeenCalledTimes(2); expect(onSelectionChange).toHaveBeenLastCalledWith('step-three'); onSelectionChange.mockReset(); - // try to select step after immediate next step - const stepFour = stepListItems[3]; - expect(stepFour).not.toHaveAttribute('aria-current'); - await user.click(stepFour); - expect(stepFour).not.toHaveAttribute('aria-current'); + // the step after the immediate next step is not navigable + expect(getSteps()[3]).not.toHaveAttribute('aria-current'); + await user.click(getSteps()[3]); + expect(getSteps()[3]).not.toHaveAttribute('aria-current'); expect(onSelectionChange).not.toHaveBeenCalled(); }); - it('allows user to change selected step via tab key only', async function () { - const tree = renderComponent({ + it('allows user to change selected step via keyboard', async function () { + const tree = renderWithRouter({ defaultLastCompletedStep: 'step-two', defaultSelectedKey: 'step-three', onSelectionChange }); const stepList = tree.getByLabelText('steplist-test'); - const stepListItems = within(stepList).getAllByRole('link'); + const getSteps = () => within(stepList).getAllByRole('link'); - expect(stepListItems[2]).toHaveAttribute('aria-current', 'step'); + expect(getSteps()[2]).toHaveAttribute('aria-current', 'step'); + // Only completed steps and the current step are tabbable; upcoming steps are skipped. await user.tab(); - expect(document.activeElement).toBe(stepListItems[0]); + expect(document.activeElement).toBe(getSteps()[0]); await user.tab(); - expect(document.activeElement).toBe(stepListItems[1]); + expect(document.activeElement).toBe(getSteps()[1]); await user.tab(); - expect(document.activeElement).toBe(stepListItems[2]); + expect(document.activeElement).toBe(getSteps()[2]); await user.tab({shift: true}); - expect(document.activeElement).toBe(stepListItems[1]); + expect(document.activeElement).toBe(getSteps()[1]); await user.keyboard('{Enter}'); expect(onSelectionChange).toHaveBeenCalled(); expect(onSelectionChange).toHaveBeenCalledWith('step-two'); - expect(stepListItems[1]).toHaveAttribute('aria-current'); + expect(getSteps()[1]).toHaveAttribute('aria-current'); onSelectionChange.mockReset(); + // Arrow keys do not change selection in a step list of links. await user.keyboard('{ArrowUp}'); - expect(stepListItems[1]).toHaveAttribute('aria-current'); + expect(getSteps()[1]).toHaveAttribute('aria-current'); expect(onSelectionChange).not.toHaveBeenCalled(); }); it('should not allow user to click on disabled steps', async function () { - const tree = renderComponent({ + const tree = renderWithRouter({ defaultLastCompletedStep: 'step-two', defaultSelectedKey: 'step-three', disabledKeys: ['step-one'], @@ -180,6 +216,7 @@ describe('StepList', function () { const stepListItems = within(stepList).getAllByRole('link'); const stepOne = stepListItems[0]; + expect(stepOne).toHaveAttribute('aria-disabled', 'true'); await user.click(stepOne); expect(stepOne).not.toHaveAttribute('aria-current'); @@ -211,7 +248,7 @@ describe('StepList', function () { }); it('should not allow user to click previous steps when step list is readonly', async function () { - const tree = renderComponent({ + const tree = renderWithRouter({ defaultSelectedKey: 'step-four', defaultLastCompletedStep: 'step-three', isReadOnly: true, @@ -246,14 +283,18 @@ describe('StepList', function () { onSelectionChange={onSelectionChange} selectedKey="step-one" items={items}> - {item => {item.value}} + {(item: StepItem) => ( + + {item.value} + + )} ); const stepList = getByLabelText('steplist-test'); const stepListItems = within(stepList).getAllByRole('link'); expect(stepListItems[0]).toHaveAttribute('aria-current'); - expect(stepListItems[0].textContent).toContain('Current'); + expect(stepListItems[0].closest('li')!.textContent).toContain('Current'); expect(onLastCompletedStepChange).not.toHaveBeenCalled(); rerender( @@ -263,12 +304,16 @@ describe('StepList', function () { onLastCompletedStepChange={onLastCompletedStepChange} selectedKey="step-two" items={items}> - {item => {item.value}} + {(item: StepItem) => ( + + {item.value} + + )} ); expect(onLastCompletedStepChange).not.toHaveBeenCalled(); - expect(stepListItems[0].textContent).toContain('Completed'); + expect(stepListItems[0].closest('li')!.textContent).toContain('Completed'); rerender( - {item => {item.value}} + {(item: StepItem) => ( + + {item.value} + + )} ); expect(onLastCompletedStepChange).toHaveBeenCalledWith('step-two'); - expect(stepListItems[1].textContent).toContain('Completed'); + expect(stepListItems[1].closest('li')!.textContent).toContain('Completed'); }); it('does not update selected step when last completed step is controlled', function () { @@ -296,7 +345,11 @@ describe('StepList', function () { onSelectionChange={onSelectionChange} onLastCompletedStepChange={onLastCompletedStepChange} items={items}> - {item => {item.value}} + {(item: StepItem) => ( + + {item.value} + + )} ); expect(onLastCompletedStepChange).toHaveBeenCalledTimes(0); @@ -313,7 +366,11 @@ describe('StepList', function () { onSelectionChange={onSelectionChange} lastCompletedStep="step-two" items={items}> - {item => {item.value}} + {(item: StepItem) => ( + + {item.value} + + )} ); @@ -325,13 +382,17 @@ describe('StepList', function () { onSelectionChange={onSelectionChange} lastCompletedStep="step-three" items={items}> - {item => {item.value}} + {(item: StepItem) => ( + + {item.value} + + )} ); expect(onLastCompletedStepChange).toHaveBeenCalledTimes(0); expect(onSelectionChange).toHaveBeenCalledTimes(1); expect(stepListItems[1]).toHaveAttribute('aria-current'); - expect(stepListItems[2].textContent).toContain('Completed'); + expect(stepListItems[2].closest('li')!.textContent).toContain('Completed'); }); }); diff --git a/packages/react-aria/src/steplist/useStepListItem.ts b/packages/react-aria/src/steplist/useStepListItem.ts index 4414dfcdf78..1bc55c0724e 100644 --- a/packages/react-aria/src/steplist/useStepListItem.ts +++ b/packages/react-aria/src/steplist/useStepListItem.ts @@ -11,7 +11,7 @@ */ import {AriaLinkOptions} from 'react-aria/useLink'; -import {HTMLAttributes, useId} from 'react'; +import {HTMLAttributes} from 'react'; import {Key, RefObject} from '@react-types/shared'; import {SelectableItemStates} from '../selection/useSelectableItem'; import {StepListState} from 'react-stately/private/steplist/useStepListState'; @@ -41,7 +41,6 @@ export function useStepListItem( let {selectionManager: manager} = state; let isDisabled = !state.isSelectable(key); - let stepId = useId(); let {itemProps, ...states} = useSelectableItem({ isDisabled, @@ -52,12 +51,11 @@ export function useStepListItem( return { stepProps: { - id: stepId, ...itemProps, tabIndex: undefined }, linkProps: { - 'aria-labelledby': stepId, + 'aria-labelledby': itemProps.id, isDisabled, 'aria-current': states.isSelected ? 'step' : undefined }, From 74e4081e54b481835c433621da16c5bf949fd6e4 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 14 Sep 2026 10:46:39 +1000 Subject: [PATCH 06/10] fix v3 with changes i made and fix rac tests --- .../src/steplist/StepListItem.tsx | 96 +++++++++---------- .../test/StepList.test.tsx | 4 +- 2 files changed, 49 insertions(+), 51 deletions(-) diff --git a/packages/@adobe/react-spectrum/src/steplist/StepListItem.tsx b/packages/@adobe/react-spectrum/src/steplist/StepListItem.tsx index 4ed72fd21af..d47886e36df 100644 --- a/packages/@adobe/react-spectrum/src/steplist/StepListItem.tsx +++ b/packages/@adobe/react-spectrum/src/steplist/StepListItem.tsx @@ -11,19 +11,16 @@ */ import ChevronRightMedium from '@spectrum-icons/ui/ChevronRightMedium'; import {classNames} from '../utils/classNames'; -import {FocusRing} from 'react-aria/FocusRing'; import intlMessages from '../../intl/steplist/*.json'; -import {mergeProps} from 'react-aria/mergeProps'; +import {Link} from 'react-aria-components/Link'; import {Node} from '@react-types/shared'; import React, {ReactNode, useContext, useRef} from 'react'; import {StepListContext} from './StepListContext'; import styles from '@adobe/spectrum-css-temp/components/steplist/vars.css'; -import {useHover} from 'react-aria/useHover'; import {useId} from 'react-aria/useId'; import {useLocale} from 'react-aria/I18nProvider'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useNumberFormatter} from 'react-aria/useNumberFormatter'; -import {useStepListItem} from 'react-aria/private/steplist/useStepListItem'; import {VisuallyHidden} from 'react-aria/VisuallyHidden'; interface SpectrumStepListItemProps { @@ -43,12 +40,9 @@ export function StepListItem(props: SpectrumStepListItemProps): ReactNode const isSelected = state.selectedKey === key; const isCompleted = state.isCompleted(key); const isItemDisabled = isDisabled || state.disabledKeys.has(key); - let {stepProps, stepStateProps} = useStepListItem({...props, key}, state, ref); - - let {hoverProps, isHovered} = useHover({ - ...props, - isDisabled: isItemDisabled || isSelected || props.isReadOnly - }); + const isSelectable = state.isSelectable(key); + // Only selectable steps other than the current one are navigable links with an href. + const href = isSelectable && !isSelected ? (item.props as {href?: string})?.href : undefined; let stepStateText = ''; const stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-spectrum/steplist'); @@ -68,52 +62,54 @@ export function StepListItem(props: SpectrumStepListItemProps): ReactNode return (
  • - - state.setSelectedKey(key)} + aria-current={isSelected ? 'step' : undefined} + aria-labelledby={`${markerId} ${stateId} ${labelId}`} + className={({isHovered, isFocusVisible}) => + classNames(styles, 'spectrum-Steplist-link', { 'is-selected': isSelected && !isItemDisabled, 'is-disabled': isItemDisabled, 'is-hovered': isHovered, 'is-completed': isCompleted, - 'is-selectable': state.isSelectable(key) && !isSelected + 'is-selectable': isSelectable && !isSelected, + 'focus-ring': isFocusVisible + }) + }> + {stepStateText} + +
  • ); } diff --git a/packages/react-aria-components/test/StepList.test.tsx b/packages/react-aria-components/test/StepList.test.tsx index 99a23cb5b68..0739bf7869d 100644 --- a/packages/react-aria-components/test/StepList.test.tsx +++ b/packages/react-aria-components/test/StepList.test.tsx @@ -127,7 +127,9 @@ describe('StepList', function () { expect(completedStateEl!.textContent).toContain('Completed'); let notCompletedStep = stepListItems[3]; - let notCompletedStateEl = document.getElementById(notCompletedStep.getAttribute('aria-labelledby')!); + let notCompletedStateEl = document.getElementById( + notCompletedStep.getAttribute('aria-labelledby')! + ); expect(notCompletedStateEl!.textContent).toContain('Not'); }); From 0749f88e64da3f9e5edaed8ee540d65d160a6b3f Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 14 Sep 2026 10:54:32 +1000 Subject: [PATCH 07/10] cleanup --- .../stories/StepList.stories.tsx | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/packages/react-aria-components/stories/StepList.stories.tsx b/packages/react-aria-components/stories/StepList.stories.tsx index 94548e08253..db30fc4544e 100644 --- a/packages/react-aria-components/stories/StepList.stories.tsx +++ b/packages/react-aria-components/stories/StepList.stories.tsx @@ -1,5 +1,5 @@ /* - * Copyright 2024 Adobe. All rights reserved. + * Copyright 2026 Adobe. All rights reserved. * This file is licensed to you under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. You may obtain a copy * of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -27,7 +27,6 @@ import {Router, Link as RouterLink} from '../../dev/s2-docs/pages/react-aria/Rou import {RouterProvider} from 'react-aria/private/utils/openLink'; import {Step, StepListTabList, StepListTabs, StepPanel, StepPanels} from '../src/StepListTabs'; import {StepList, StepListItem} from '../src/StepList'; -// Reuse the same fake documentation router that NavigationTree.mdx uses. import styles from './styles.css'; import {Text} from '../src/Text'; @@ -210,8 +209,7 @@ export const CoworkerStepListExample: StepListStory = { }}
    - {/* Upcoming steps aren't selectable via click, so forward navigation goes through - the router as well — updating the route drives the controlled selectedKey. */} + {/* Upcoming steps aren't selectable via click, so provide a different mechanism to progress. */}
    Prev Next @@ -242,8 +240,6 @@ let tabStepItems: Array = [ {id: 'monitor', title: 'Monitor', stage: 5, Icon: Rocket} ]; -// The chevron marker/labels for a single step. Completed steps swap the icon for a check -// via the [data-completed] hook that StepListTabs sets on each step's content. function TabStepContent({item}: {item: TabStepItem}) { let Icon = item.Icon; return ( @@ -264,7 +260,6 @@ function TabStepContent({item}: {item: TabStepItem}) { ); } -// Static children variant. export const TabBasedStepList: StepListStory = { render: (args: any) => (
    @@ -274,7 +269,7 @@ export const TabBasedStepList: StepListStory = { aria-label="Coworker steps" className={styles['coworker-StepListTabList']}> {tabStepItems.map(item => ( - + ))} @@ -282,7 +277,7 @@ export const TabBasedStepList: StepListStory = {
    {tabStepItems.map(item => ( - + {`${item.title} — stage ${item.stage} content`} ))} @@ -344,7 +339,6 @@ export const DynamicTabBasedStepList: StepListStory = { render: (args: any) => }; -// Router-driven variant: each step is a link (href) interface RouterStepItem extends TabStepItem { url: string; } From 2122210ac3c7a12cca0ac08635b120e2d9e1b968 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 14 Sep 2026 11:01:36 +1000 Subject: [PATCH 08/10] add switchable content area --- .../stories/StepList.stories.tsx | 8 +++++++ .../react-aria-components/stories/styles.css | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/react-aria-components/stories/StepList.stories.tsx b/packages/react-aria-components/stories/StepList.stories.tsx index db30fc4544e..c06aa4c42d0 100644 --- a/packages/react-aria-components/stories/StepList.stories.tsx +++ b/packages/react-aria-components/stories/StepList.stories.tsx @@ -166,6 +166,7 @@ export const CoworkerStepListExample: StepListStory = { {({selectedRoute}) => { let currentIndex = coworkerItems.findIndex(item => item.url === selectedRoute); + let currentItem = coworkerItems[Math.max(currentIndex, 0)]; let prevItem = coworkerItems[Math.max(currentIndex - 1, 0)]; let nextItem = coworkerItems[Math.min(currentIndex + 1, coworkerItems.length - 1)]; return ( @@ -209,6 +210,13 @@ export const CoworkerStepListExample: StepListStory = { }}
    + {/* Content area driven by the current route: its contents change as the selected step changes. */} +
    +

    {currentItem.title}

    +

    + {`Stage ${currentItem.stage} of ${coworkerItems.length}. This content updates based on the current route (${selectedRoute}).`} +

    +
    {/* Upcoming steps aren't selectable via click, so provide a different mechanism to progress. */}
    Prev diff --git a/packages/react-aria-components/stories/styles.css b/packages/react-aria-components/stories/styles.css index 1b4b377c30b..7c99ce6cde6 100644 --- a/packages/react-aria-components/stories/styles.css +++ b/packages/react-aria-components/stories/styles.css @@ -875,3 +875,25 @@ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 13px; } + +/* Route-driven content area for the Coworker StepList example. */ +.coworker-StepList-panel { + padding: 16px; + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.07); + background: #141414; + color: #f5f5f5; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +.coworker-StepList-panel-title { + margin: 0 0 4px; + font-size: 15px; + font-weight: 700; +} + +.coworker-StepList-panel-body { + margin: 0; + font-size: 13px; + color: #a1a1a1; +} From 38f6b88be86f403cdd7a1240f89c4e4edb8ce55c Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 16 Sep 2026 14:32:51 +1000 Subject: [PATCH 09/10] hopefully improvements to announcements --- packages/react-aria-components/src/StepListTabs.tsx | 2 +- packages/react-aria/src/steplist/useStepListItem.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-aria-components/src/StepListTabs.tsx b/packages/react-aria-components/src/StepListTabs.tsx index 9826e914118..d172299d5da 100644 --- a/packages/react-aria-components/src/StepListTabs.tsx +++ b/packages/react-aria-components/src/StepListTabs.tsx @@ -145,7 +145,7 @@ function StepState({stepKey, children}: {stepKey: Key | undefined; children: Rea style={{display: 'contents'}} data-current={isCurrent || undefined} data-completed={isCompleted || undefined}> - {stateText} + {!isCurrent && {stateText}} {children} ); diff --git a/packages/react-aria/src/steplist/useStepListItem.ts b/packages/react-aria/src/steplist/useStepListItem.ts index 1bc55c0724e..a0513d1bec5 100644 --- a/packages/react-aria/src/steplist/useStepListItem.ts +++ b/packages/react-aria/src/steplist/useStepListItem.ts @@ -52,10 +52,10 @@ export function useStepListItem( return { stepProps: { ...itemProps, + role: 'listitem', tabIndex: undefined }, linkProps: { - 'aria-labelledby': itemProps.id, isDisabled, 'aria-current': states.isSelected ? 'step' : undefined }, From 5d81bc09e6b31ae7779c4187a4f6233f30734ff6 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Wed, 16 Sep 2026 14:43:12 +1000 Subject: [PATCH 10/10] fix tests --- packages/react-aria-components/test/StepList.test.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/react-aria-components/test/StepList.test.tsx b/packages/react-aria-components/test/StepList.test.tsx index 0739bf7869d..0d3c584c634 100644 --- a/packages/react-aria-components/test/StepList.test.tsx +++ b/packages/react-aria-components/test/StepList.test.tsx @@ -119,18 +119,13 @@ describe('StepList', function () { // Each step link is labelled by its containing item, which includes the visually hidden // step state text, so the state is part of the link's accessible name. let currentStep = stepListItems[2]; - let stateEl = document.getElementById(currentStep.getAttribute('aria-labelledby')!); - expect(stateEl!.textContent).toContain('Current'); + expect(currentStep!.getAttribute('aria-current')).toBeTruthy(); let completedStep = stepListItems[0]; - let completedStateEl = document.getElementById(completedStep.getAttribute('aria-labelledby')!); - expect(completedStateEl!.textContent).toContain('Completed'); + expect(completedStep!.parentElement!.textContent).toContain('Completed'); let notCompletedStep = stepListItems[3]; - let notCompletedStateEl = document.getElementById( - notCompletedStep.getAttribute('aria-labelledby')! - ); - expect(notCompletedStateEl!.textContent).toContain('Not'); + expect(notCompletedStep!.parentElement!.textContent).toContain('Not'); }); it('attaches a user provided ref', function () {