From 64b33ec3f528ed58e6027273d1da67006170bc16 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 16 Oct 2026 16:27:58 +1100 Subject: [PATCH 1/4] feat: StepList round two --- .../react-aria-components/intl/en-US.json | 5 +- .../react-aria-components/src/StepList.tsx | 243 ++++++++++++++++++ .../stories/StepList.css | 59 +++++ .../stories/StepList.stories.tsx | 80 ++++++ 4 files changed, 386 insertions(+), 1 deletion(-) create mode 100644 packages/react-aria-components/src/StepList.tsx create mode 100644 packages/react-aria-components/stories/StepList.css create mode 100644 packages/react-aria-components/stories/StepList.stories.tsx 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..5d625faea2a --- /dev/null +++ b/packages/react-aria-components/src/StepList.tsx @@ -0,0 +1,243 @@ +/* + * 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} from '@react-types/shared'; +import { + ClassNameOrFunction, + ContextValue, + dom, + DOMProps, + RenderProps, + SlotProps, + useContextProps, + useRenderProps +} from './utils'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {getOwnerDocument} from 'react-aria/private/utils/domHelpers'; +import intlMessages from '../intl/*.json'; +import {mergeProps} from 'react-aria/mergeProps'; +import React, { + createContext, + ForwardedRef, + forwardRef, + RefObject, + useContext, + useState +} from 'react'; +import {useId} from 'react-aria/useId'; +import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; +import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; +import {useObjectRef} from 'react-aria/useObjectRef'; + +export interface StepListRenderProps {} + +export interface StepListProps + extends + SlotProps, + RenderProps, + GlobalDOMAttributes, + AriaLabelingProps { + /** + * 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-StepList' + */ + className?: ClassNameOrFunction; + /** + * The `id` of the currently selected `StepListItem`. Steps rendered before it are marked as + * completed, and steps rendered after it are marked as not completed. + */ + selectedId: string; +} + +export const StepListContext = createContext>(null); + +interface StepListState { + /** The `id` of the currently selected step. */ + selectedId: string; + /** Identifies the steps belonging to this list so an item can locate its sibling steps. */ + groupId: string; +} + +const InternalStepListContext = createContext(null); + +/** + * A StepList displays the steps of a multi step process, and which of them the user has completed. + */ +export const StepList = /*#__PURE__*/ (forwardRef as forwardRefType)(function StepList( + props: StepListProps, + ref: ForwardedRef +) { + [props, ref] = useContextProps(props, ref, StepListContext); + let {selectedId} = props; + let groupId = useId(); + + let renderProps = useRenderProps({ + ...props, + values: {}, + defaultClassName: 'react-aria-StepList' + }); + + let DOMProps = filterDOMProps(props, {labelable: true}); + + return ( + + + {renderProps.children} + + + ); +}); + +export interface StepListItemRenderProps { + /** + * Whether the step is the currently selected step. + * + * @selector [data-current] + */ + isCurrent: boolean; + /** + * Whether the step is rendered before the currently selected step. + * + * @selector [data-completed] + */ + isCompleted: boolean; +} + +export interface StepListItemProps + extends + RenderProps, + GlobalDOMAttributes, + AriaLabelingProps { + /** + * 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, which is compared against the `selectedId` of the `StepList`. */ + id: string; +} + +function useStepListItem( + props: StepListItemProps, + state: StepListState | null, + ref: RefObject +) { + let {id} = props; + let groupId = state?.groupId; + let selectedId = state?.selectedId; + let isCurrent = id === selectedId; + // A step is completed if it is rendered before the current step. This can only be determined once + // all of the steps in the group have been rendered, so it is resolved against the DOM. + let [isCompleted, setCompleted] = useState(false); + + useLayoutEffect(() => { + if (!ref.current || groupId == null || isCurrent) { + setCompleted(false); + return; + } + + let currentItem = getOwnerDocument(ref.current).querySelector( + `[data-steplist-group-id="${groupId}"][data-current]` + ); + setCompleted( + currentItem != null && + (currentItem.compareDocumentPosition(ref.current) & Node.DOCUMENT_POSITION_PRECEDING) !== 0 + ); + }, [isCurrent, selectedId, groupId, ref]); + + return { + itemProps: { + 'aria-current': isCurrent ? ('step' as const) : undefined, + 'data-steplist-group-id': groupId, + 'data-current': isCurrent || undefined, + 'data-completed': isCompleted || undefined + }, + isCurrent, + isCompleted + }; +} + +/** + * A StepListItem represents an individual step within a ``. + */ +export const StepListItem = /*#__PURE__*/ (forwardRef as forwardRefType)(function StepListItem( + props: StepListItemProps, + ref: ForwardedRef +) { + let objRef = useObjectRef(ref); + let state = useContext(InternalStepListContext); + let stringFormatter = useLocalizedStringFormatter(intlMessages, 'react-aria-components'); + let {itemProps, isCurrent, isCompleted} = useStepListItem(props, state, objRef); + let hiddenId = useId(); + + let renderProps = useRenderProps({ + ...props, + values: {isCurrent, isCompleted}, + defaultClassName: 'react-aria-StepListItem' + }); + + let DOMProps = filterDOMProps(props, {labelable: true}); + // `id` identifies the step rather than the DOM element, so it is not forwarded to the DOM. + // TODO IS THIS WEIRD OR WRONG?? + delete DOMProps.id; + + let message = stringFormatter.format('stepListItemNotCompleted'); + if (isCurrent) { + message = stringFormatter.format('stepListItemCurrent'); + } else if (isCompleted) { + message = stringFormatter.format('stepListItemCompleted'); + } + + return ( + + + + {renderProps.children} + + + ); +}); + +interface StepListItemContentProps extends DOMProps {} +const StepListItemContext = createContext<{hiddenId: string | undefined} | undefined>(undefined); + +// Need this so I can get an id on the contents so that the announcement makes sense. It lands on this element, not the `li` itself. +export const StepListItemContent = /*#__PURE__*/ (forwardRef as forwardRefType)( + function StepListItemContent(props: StepListItemContentProps, ref: ForwardedRef) { + let context = useContext(StepListItemContext); + let objRef = useObjectRef(ref); + let DOMProps = filterDOMProps(props, {labelable: true}); + let id = useId(); + + return ( + + {props.children} + + ); + } +); diff --git a/packages/react-aria-components/stories/StepList.css b/packages/react-aria-components/stories/StepList.css new file mode 100644 index 00000000000..0f3c2aa9aad --- /dev/null +++ b/packages/react-aria-components/stories/StepList.css @@ -0,0 +1,59 @@ +.react-aria-StepList { + display: flex; + gap: 12px; + margin: 0; + padding: 0 0 24px; + list-style: none; +} + +.react-aria-StepListItem { + --marker-background: var(--spectrum-global-color-gray-200); + --marker-color: var(--spectrum-global-color-gray-700); + --label-color: var(--spectrum-global-color-gray-700); + --line: 2px dashed var(--spectrum-global-color-gray-400); + + display: flex; + align-items: center; + gap: 12px; + + &[data-current], + &[data-completed] { + --marker-background: var(--spectrum-global-color-gray-800); + --marker-color: var(--spectrum-global-color-gray-50); + --label-color: var(--spectrum-global-color-gray-900); + } + + &[data-completed] { + --line: 2px solid var(--spectrum-global-color-gray-800); + } + + /* The connector drawn between this step and the one after it. */ + &:not(:last-child)::after { + content: ''; + width: 96px; + border-top: var(--line); + } + + .marker { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 8px; + background: var(--marker-background); + color: var(--marker-color); + font-size: 14px; + } + + .label { + position: absolute; + top: 28px; + left: 50%; + transform: translateX(-50%); + color: var(--label-color); + font-size: 12px; + white-space: nowrap; + } +} 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..fd02813a64f --- /dev/null +++ b/packages/react-aria-components/stories/StepList.stories.tsx @@ -0,0 +1,80 @@ +/* + * 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 {Meta, StoryObj} from '@storybook/react'; +import React, {ReactNode} from 'react'; +import {StepList, StepListItem, StepListItemContent} from '../src/StepList'; +import './StepList.css'; + +const steps = [ + {id: 'domain', label: 'Domain/Sender details'}, + {id: 'dns', label: 'Configure DNS'}, + {id: 'advanced', label: 'Advanced settings'} +]; + +export default { + title: 'React Aria Components/StepList', + component: StepList, + args: { + selectedId: 'dns' + }, + argTypes: { + selectedId: { + control: 'radio', + options: steps.map(step => step.id) + } + } +} as Meta; + +export type StepListStory = StoryObj; + +function CheckIcon(): ReactNode { + return ( + + ); +} + +export const StepListExample: StepListStory = { + render: (args: any) => ( +
+ + + {steps.map((step, index) => ( + + {({isCompleted}) => ( + + {isCompleted ? ( + + ) : ( + + )} + + {step.label} + + + )} + + ))} + +
+ ) +}; From 19730beda8e2cf18d0f3549cefc3def052a16c14 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Mon, 19 Oct 2026 16:06:45 +1100 Subject: [PATCH 2/4] add Wizard --- packages/react-aria-components/src/Wizard.tsx | 334 ++++++++++++++++++ .../react-aria-components/stories/Wizard.css | 115 ++++++ .../stories/Wizard.stories.tsx | 84 +++++ 3 files changed, 533 insertions(+) create mode 100644 packages/react-aria-components/src/Wizard.tsx create mode 100644 packages/react-aria-components/stories/Wizard.css create mode 100644 packages/react-aria-components/stories/Wizard.stories.tsx diff --git a/packages/react-aria-components/src/Wizard.tsx b/packages/react-aria-components/src/Wizard.tsx new file mode 100644 index 00000000000..cbcfc516dbd --- /dev/null +++ b/packages/react-aria-components/src/Wizard.tsx @@ -0,0 +1,334 @@ +/* + * 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, + useContext, + useEffect, + useMemo +} from 'react'; +import { + Tab, + TabList, + TabListProps, + TabListStateContext, + TabPanel, + TabPanelProps, + TabPanels, + TabPanelsProps, + TabProps, + Tabs, + TabsProps +} from './Tabs'; +import {useControlledState} from 'react-stately/useControlledState'; +import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; +import {VisuallyHidden} from 'react-aria/VisuallyHidden'; + +export interface WizardProps extends Omit { + /** The children of the component (a `WizardStepList` and optional `WizardPanels`). */ + 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 by default. + */ + 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 every step that is not in `disabledKeys` is navigable at any time, rather than only + * completed steps and the one after them. + */ + allowsRandomAccess?: boolean; +} + +/** + * A Wizard displays a sequence of steps. + */ +export const Wizard = /*#__PURE__*/ forwardRef(function Wizard( + props: WizardProps, + ref: ForwardedRef +) { + let {children} = props; + return ( + + {collection => } + + ); +}); + +interface WizardInnerProps { + props: WizardProps; + collection: ICollection>; + tabsRef: ForwardedRef; +} + +export interface WizardStateOptions { + /** Ordered list of step keys, in collection order. */ + keys: Key[]; + /** 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). */ + 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 every step that is not in `disabledKeys` is navigable at any time. */ + allowsRandomAccess?: boolean; + /** The step keys that are disabled. These steps are never navigable. */ + disabledKeys?: Iterable; +} + +export interface WizardState { + /** 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 by default. + */ + lastCompletedStep: Key | null; + /** + * Keys that are not navigable. + */ + disabledKeys: Set; +} + +/** + * 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 + * unless random access is allowed. The last completed step advances as the selection moves forward + * and is never rolled back unless it's in a controlled state, + * so previously reached steps stay navigable after navigating backwards. + */ +export function useWizardState(options: WizardStateOptions): WizardState { + let { + keys, + allowsRandomAccess = false, + disabledKeys: disabledStepKeys, + 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(() => { + // Steps disabled by the consumer are never navigable, however the wizard is configured. + let set = new Set(disabledStepKeys); + keys.forEach((key, i) => { + if (key === selectedKey) { + return; + } + // Completed steps and the current step are navigable; upcoming steps are only if allowsRandomAccess. + if (!allowsRandomAccess && i > lastCompletedIndex + 1) { + set.add(key); + } + }); + return set; + }, [keys, selectedKey, lastCompletedIndex, allowsRandomAccess, disabledStepKeys]); + + return { + selectedKey, + setSelectedKey, + lastCompletedStep, + disabledKeys + }; +} + +function WizardInner({props, collection, tabsRef: ref}: WizardInnerProps) { + let { + children, + selectedKey, + defaultSelectedKey, + onSelectionChange, + lastCompletedStep, + defaultLastCompletedStep, + onLastCompletedStepChange, + allowsRandomAccess, + disabledKeys, + ...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 = useWizardState({ + keys, + selectedKey, + defaultSelectedKey, + onSelectionChange, + lastCompletedStep, + defaultLastCompletedStep, + onLastCompletedStepChange, + allowsRandomAccess, + disabledKeys + }); + + return ( + + {children} + + ); +} + +export interface WizardStepListProps extends TabListProps {} + +/** The list of steps within a `Wizard` (wraps `TabList`). */ +export const WizardStepList = /*#__PURE__*/ (forwardRef as forwardRefType)(function WizardStepList< + T extends object +>(props: WizardStepListProps, ref: ForwardedRef) { + return ; +}); + +export interface WizardStepProps extends TabProps {} + +export interface WizardStepAria { + /** Whether this step is the current (selected) step. */ + isCurrent: boolean; + /** Whether this step has been completed (precedes the current step). */ + isCompleted: boolean; +} + +/** + * Returns whether the step with the given key is the current step of the enclosing `Wizard`, and + * whether it has already been completed (it is rendered before the current step). + */ +export function useWizardStep(key: Key | undefined): WizardStepAria { + let state = useContext(TabListStateContext); + + if (state == null || key == null || state.selectedKey == null) { + return {isCurrent: false, isCompleted: false}; + } + + let isCurrent = state.selectedKey === key; + + // Walk backwards from the current step. If we reach this step, it precedes the current + // step and is therefore completed. + // Should be ok for performance, wizards are generally short. + let isCompleted = false; + let cursor = state.collection.getKeyBefore(state.selectedKey); + while (cursor != null) { + if (cursor === key) { + isCompleted = true; + break; + } + cursor = state.collection.getKeyBefore(cursor); + } + + return {isCurrent, isCompleted}; +} + +function WizardStepState({stepKey, children}: {stepKey: Key | undefined; children: ReactNode}) { + let {isCurrent, isCompleted} = useWizardStep(stepKey); + let stringFormatter = useLocalizedStringFormatter(intlMessages, 'react-aria-components'); + + let stateText = isCurrent + ? stringFormatter.format('stepListItemCurrent') + : isCompleted + ? stringFormatter.format('stepListItemCompleted') + : stringFormatter.format('stepListItemNotCompleted'); + + return ( + + {!isCurrent && {stateText}} + {children} + + ); +} + +export const WizardStep = /*#__PURE__*/ forwardRef(function WizardStep( + props: WizardStepProps, + ref: ForwardedRef +) { + let {children, ...otherProps} = props; + return ( + + {renderProps => ( + + {typeof children === 'function' ? children(renderProps) : children} + + )} + + ); +}) as (props: WizardStepProps & {ref?: ForwardedRef}) => ReactElement; + +export interface WizardPanelsProps extends TabPanelsProps {} + +export const WizardPanels = /*#__PURE__*/ (forwardRef as forwardRefType)(function WizardPanels< + T extends object +>(props: WizardPanelsProps, ref: ForwardedRef) { + return ; +}); + +export interface WizardPanelProps extends TabPanelProps {} + +export const WizardPanel = /*#__PURE__*/ forwardRef(function WizardPanel( + props: WizardPanelProps, + ref: ForwardedRef +) { + return ; +}); diff --git a/packages/react-aria-components/stories/Wizard.css b/packages/react-aria-components/stories/Wizard.css new file mode 100644 index 00000000000..8bb0df27d3e --- /dev/null +++ b/packages/react-aria-components/stories/Wizard.css @@ -0,0 +1,115 @@ +.wizard { + /* Width of the chevron notch cut into each end of a step. */ + --arrow: 14px; + /* How far a step slides under the previous one so the chevrons interlock. */ + --overlap: 12px; + --padding: 12px; + + .react-aria-TabList { + display: flex; + overflow-x: auto; + overflow-y: hidden; + border-radius: 16px; + background: var(--spectrum-global-color-gray-300); + } + + .react-aria-Tab { + display: flex; + flex: 1 1 0; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px calc(var(--arrow) + var(--padding)); + margin-left: calc(-1 * var(--overlap)); + background: var(--spectrum-global-color-gray-200); + outline: none; + cursor: pointer; + clip-path: polygon( + 0 0, + calc(100% - var(--arrow)) 0, + 100% 50%, + calc(100% - var(--arrow)) 100%, + 0 100%, + var(--arrow) 50% + ); + + &:first-child { + padding-left: var(--padding); + margin-left: 0; + clip-path: polygon( + 0 0, + calc(100% - var(--arrow)) 0, + 100% 50%, + calc(100% - var(--arrow)) 100%, + 0 100% + ); + } + + &:last-child { + padding-right: var(--padding); + clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, var(--arrow) 50%); + } + + &[data-selected], + &[data-hovered] { + background: var(--spectrum-global-color-gray-100); + } + + /* Steps that are not navigable yet, or every step but the current one when read only. */ + &[data-disabled] { + cursor: default; + } + + &[data-focus-visible] .marker { + outline: 2px solid var(--spectrum-alias-focus-ring-color); + outline-offset: 2px; + } + } + + .marker { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 9999px; + background: var(--spectrum-global-color-gray-400); + color: var(--spectrum-global-color-gray-800); + font-size: 14px; + } + + [data-completed] .marker { + background: var(--spectrum-global-color-green-600); + color: var(--spectrum-global-color-gray-50); + } + + [data-current] .marker { + background: var(--spectrum-global-color-blue-600); + color: var(--spectrum-global-color-gray-50); + } + + .text { + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; + } + + .label, + .position { + overflow: hidden; + font-size: 12px; + line-height: 1.5; + white-space: nowrap; + text-overflow: ellipsis; + } + + .label { + color: var(--spectrum-global-color-gray-800); + } + + .position { + color: var(--spectrum-global-color-gray-700); + } +} diff --git a/packages/react-aria-components/stories/Wizard.stories.tsx b/packages/react-aria-components/stories/Wizard.stories.tsx new file mode 100644 index 00000000000..d155a975f65 --- /dev/null +++ b/packages/react-aria-components/stories/Wizard.stories.tsx @@ -0,0 +1,84 @@ +/* + * 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 {Meta, StoryObj} from '@storybook/react'; +import React, {ReactNode} from 'react'; +import {useWizardStep, Wizard, WizardStep, WizardStepList} from '../src/Wizard'; +import './Wizard.css'; + +const steps = [ + {id: 'domain', label: 'Domain/Sender details'}, + {id: 'dns', label: 'Configure DNS'}, + {id: 'advanced', label: 'Advanced settings'} +]; + +export default { + title: 'React Aria Components/Wizard', + component: Wizard, + args: { + allowsRandomAccess: false + } +} as Meta; + +export type WizardStory = StoryObj; + +function CheckIcon(): ReactNode { + return ( + + ); +} + +// The number and check mark repeat the label and the state that `WizardStep` already announces, +// so they are decorative. +function StepMarker({stepId, index}: {stepId: string; index: number}): ReactNode { + let {isCompleted} = useWizardStep(stepId); + return ( + + ); +} + +export const WizardExample: WizardStory = { + render: (args: any) => ( +
+ + + + {steps.map((step, index) => ( + + + + {step.label} + + Step {index + 1} of {steps.length} + + + + ))} + + +
+ ) +}; From e2fd60bd80872b98283154a4eafc0d803a9d42ec Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 23 Oct 2026 13:02:22 +1100 Subject: [PATCH 3/4] update readonly to collection and support anchor rendering, add tests --- .../react-aria-components/src/StepList.tsx | 267 ++++++++-------- .../stories/StepList.css | 19 ++ .../stories/StepList.stories.tsx | 73 ++++- .../test/StepList.test.js | 296 ++++++++++++++++++ 4 files changed, 514 insertions(+), 141 deletions(-) create mode 100644 packages/react-aria-components/test/StepList.test.js diff --git a/packages/react-aria-components/src/StepList.tsx b/packages/react-aria-components/src/StepList.tsx index 5d625faea2a..35b4371a790 100644 --- a/packages/react-aria-components/src/StepList.tsx +++ b/packages/react-aria-components/src/StepList.tsx @@ -10,63 +10,69 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, forwardRefType, GlobalDOMAttributes} from '@react-types/shared'; +import { + AriaLabelingProps, + forwardRefType, + GlobalDOMAttributes, + Key, + LinkDOMProps, + Node +} from '@react-types/shared'; import { ClassNameOrFunction, ContextValue, dom, DOMProps, + 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 {getOwnerDocument} from 'react-aria/private/utils/domHelpers'; import intlMessages from '../intl/*.json'; import {mergeProps} from 'react-aria/mergeProps'; -import React, { - createContext, - ForwardedRef, - forwardRef, - RefObject, - useContext, - useState -} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, useContext} from 'react'; import {useId} from 'react-aria/useId'; -import {useLayoutEffect} from 'react-aria/private/utils/useLayoutEffect'; +import {useLink} from 'react-aria/useLink'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; import {useObjectRef} from 'react-aria/useObjectRef'; -export interface StepListRenderProps {} - -export interface StepListProps +export interface StepListProps extends + Omit, 'disabledKeys'>, + StyleProps, SlotProps, - RenderProps, - GlobalDOMAttributes, - AriaLabelingProps { + AriaLabelingProps, + DOMRenderProps<'ol', undefined>, + 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. + * element. * * @default 'react-aria-StepList' */ - className?: ClassNameOrFunction; + className?: string; /** - * The `id` of the currently selected `StepListItem`. Steps rendered before it are marked as - * completed, and steps rendered after it are marked as not completed. + * The `id` of the currently selected `StepListItem`. Steps before it in the collection are marked + * as completed, and steps after it are marked as not completed. */ - selectedId: string; + selectedKey: Key; } -export const StepListContext = createContext>(null); +export const StepListContext = + createContext, HTMLOListElement>>(null); interface StepListState { - /** The `id` of the currently selected step. */ - selectedId: string; - /** Identifies the steps belonging to this list so an item can locate its sibling steps. */ - groupId: string; + /** The key of the currently selected step. */ + selectedKey: Key; + /** The index of the currently selected step within the collection, or -1 if it isn't present. */ + selectedIndex: number; } const InternalStepListContext = createContext(null); @@ -74,32 +80,33 @@ const InternalStepListContext = createContext(null); /** * A StepList displays the steps of a multi step process, and which of them the user has completed. */ -export const StepList = /*#__PURE__*/ (forwardRef as forwardRefType)(function StepList( - props: StepListProps, +export const StepList = /*#__PURE__*/ (forwardRef as forwardRefType)(function StepList( + props: StepListProps, ref: ForwardedRef ) { [props, ref] = useContextProps(props, ref, StepListContext); - let {selectedId} = props; - let groupId = useId(); - - let renderProps = useRenderProps({ - ...props, - values: {}, - defaultClassName: 'react-aria-StepList' - }); - - let DOMProps = filterDOMProps(props, {labelable: true}); + let {CollectionRoot} = useContext(CollectionRendererContext); + let {selectedKey} = props; + let DOMProps = filterDOMProps(props, {global: true, labelable: true}); return ( - - - {renderProps.children} - - + }> + {collection => ( + + + + + + )} + ); }); @@ -116,6 +123,8 @@ export interface StepListItemRenderProps { * @selector [data-completed] */ isCompleted: boolean; + /** The position of the step within the `StepList`, starting from 1. */ + stepNumber: number; } export interface StepListItemProps @@ -130,114 +139,106 @@ export interface StepListItemProps * @default 'react-aria-StepListItem' */ className?: ClassNameOrFunction; - /** A unique id for the step, which is compared against the `selectedId` of the `StepList`. */ - id: string; + /** A unique id for the step, which is compared against the `selectedKey` of the `StepList`. */ + id?: Key; } -function useStepListItem( - props: StepListItemProps, - state: StepListState | null, - ref: RefObject -) { - let {id} = props; - let groupId = state?.groupId; - let selectedId = state?.selectedId; - let isCurrent = id === selectedId; - // A step is completed if it is rendered before the current step. This can only be determined once - // all of the steps in the group have been rendered, so it is resolved against the DOM. - let [isCompleted, setCompleted] = useState(false); - - useLayoutEffect(() => { - if (!ref.current || groupId == null || isCurrent) { - setCompleted(false); - return; - } - - let currentItem = getOwnerDocument(ref.current).querySelector( - `[data-steplist-group-id="${groupId}"][data-current]` - ); - setCompleted( - currentItem != null && - (currentItem.compareDocumentPosition(ref.current) & Node.DOCUMENT_POSITION_PRECEDING) !== 0 - ); - }, [isCurrent, selectedId, groupId, ref]); - - return { - itemProps: { - 'aria-current': isCurrent ? ('step' as const) : undefined, - 'data-steplist-group-id': groupId, - 'data-current': isCurrent || undefined, - 'data-completed': isCompleted || undefined - }, - isCurrent, - isCompleted - }; +class StepListItemNode extends CollectionNode { + static readonly type = 'item'; } /** * A StepListItem represents an individual step within a ``. */ -export const StepListItem = /*#__PURE__*/ (forwardRef as forwardRefType)(function StepListItem( - props: StepListItemProps, - ref: ForwardedRef -) { - let objRef = useObjectRef(ref); - let state = useContext(InternalStepListContext); - let stringFormatter = useLocalizedStringFormatter(intlMessages, 'react-aria-components'); - let {itemProps, isCurrent, isCompleted} = useStepListItem(props, state, objRef); - let hiddenId = useId(); - - let renderProps = useRenderProps({ - ...props, - values: {isCurrent, isCompleted}, - defaultClassName: 'react-aria-StepListItem' - }); - - let DOMProps = filterDOMProps(props, {labelable: true}); - // `id` identifies the step rather than the DOM element, so it is not forwarded to the DOM. - // TODO IS THIS WEIRD OR WRONG?? - delete DOMProps.id; - - let message = stringFormatter.format('stepListItemNotCompleted'); - if (isCurrent) { - message = stringFormatter.format('stepListItemCurrent'); - } else if (isCompleted) { - message = stringFormatter.format('stepListItemCompleted'); +export const StepListItem = /*#__PURE__*/ createLeafComponent( + StepListItemNode, + function StepListItem( + props: StepListItemProps, + ref: ForwardedRef, + node: Node + ) { + let state = useContext(InternalStepListContext); + let stringFormatter = useLocalizedStringFormatter(intlMessages, 'react-aria-components'); + let hiddenId = useId(); + + let isCurrent = state != null && node.key === state.selectedKey; + // A step is completed if it comes before the current step in the collection. + let isCompleted = state != null && state.selectedIndex >= 0 && node.index < state.selectedIndex; + + let renderProps = useRenderProps({ + ...node.props, + children: node.rendered, + values: {isCurrent, isCompleted, stepNumber: node.index + 1}, + defaultClassName: 'react-aria-StepListItem' + }); + + let DOMProps = filterDOMProps(props as any, {global: true, labelable: true}); + // `id` identifies the step rather than the DOM element, so it is not forwarded to the DOM. + delete DOMProps.id; + + let message = stringFormatter.format('stepListItemNotCompleted'); + if (isCurrent) { + message = stringFormatter.format('stepListItemCurrent'); + } else if (isCompleted) { + message = stringFormatter.format('stepListItemCompleted'); + } + + return ( + + + + {renderProps.children} + + + ); } +); - return ( - - - - {renderProps.children} - - - ); -}); +export interface StepListItemContentProps + extends + DOMProps, + LinkDOMProps, + AriaLabelingProps, + Omit, 'onClick'> {} -interface StepListItemContentProps extends DOMProps {} const StepListItemContext = createContext<{hiddenId: string | undefined} | undefined>(undefined); -// Need this so I can get an id on the contents so that the announcement makes sense. It lands on this element, not the `li` itself. +// Need this so I can get an id on the contents so that the announcement makes sense. +// It lands on this element, not the `li` itself. export const StepListItemContent = /*#__PURE__*/ (forwardRef as forwardRefType)( - function StepListItemContent(props: StepListItemContentProps, ref: ForwardedRef) { + function StepListItemContent( + props: StepListItemContentProps, + ref: ForwardedRef + ) { let context = useContext(StepListItemContext); let objRef = useObjectRef(ref); - let DOMProps = filterDOMProps(props, {labelable: true}); let id = useId(); + // A step is only navigable when it is given an `href`. Otherwise `role="img"` is applied to a + // non-focusable element purely so that screen readers treat the contents as a single unit. + let isLink = props.href != null; + let elementType = isLink ? 'a' : 'div'; + let {linkProps} = useLink({...props, elementType}, objRef); + let ElementType = dom[elementType]; + let DOMProps = filterDOMProps(props, {global: true, labelable: true, isLink}); + delete DOMProps.id; return ( - {props.children} - + ); } ); diff --git a/packages/react-aria-components/stories/StepList.css b/packages/react-aria-components/stories/StepList.css index 0f3c2aa9aad..cbadb9527db 100644 --- a/packages/react-aria-components/stories/StepList.css +++ b/packages/react-aria-components/stories/StepList.css @@ -57,3 +57,22 @@ white-space: nowrap; } } + +.react-aria-StepListItemContent { + /* Stretch over the marker so links have a sensible hit area and focus ring. The label is + absolutely positioned against this element, which occupies the same box as the marker. */ + position: absolute; + inset: 0; + border-radius: inherit; + color: inherit; + text-decoration: none; + + &:is(a):hover .label { + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid var(--spectrum-alias-focus-ring-color); + outline-offset: 2px; + } +} diff --git a/packages/react-aria-components/stories/StepList.stories.tsx b/packages/react-aria-components/stories/StepList.stories.tsx index fd02813a64f..bfb04816d4a 100644 --- a/packages/react-aria-components/stories/StepList.stories.tsx +++ b/packages/react-aria-components/stories/StepList.stories.tsx @@ -16,19 +16,19 @@ import {StepList, StepListItem, StepListItemContent} from '../src/StepList'; import './StepList.css'; const steps = [ - {id: 'domain', label: 'Domain/Sender details'}, - {id: 'dns', label: 'Configure DNS'}, - {id: 'advanced', label: 'Advanced settings'} + {id: 'domain', label: 'Domain/Sender details', href: '#domain'}, + {id: 'dns', label: 'Configure DNS', href: '#dns'}, + {id: 'advanced', label: 'Advanced settings', href: '#advanced'} ]; export default { title: 'React Aria Components/StepList', component: StepList, args: { - selectedId: 'dns' + selectedKey: 'dns' }, argTypes: { - selectedId: { + selectedKey: { control: 'radio', options: steps.map(step => step.id) } @@ -56,15 +56,15 @@ export const StepListExample: StepListStory = {
- {steps.map((step, index) => ( + {steps.map(step => ( - {({isCompleted}) => ( + {({isCompleted, stepNumber}) => ( {isCompleted ? ( ) : ( )} @@ -78,3 +78,60 @@ export const StepListExample: StepListStory = {
) }; + +export const StepListDynamic: StepListStory = { + render: (args: any) => ( +
+ + + {(step: (typeof steps)[number]) => ( + + {({isCompleted, stepNumber}) => ( + + {isCompleted ? ( + + ) : ( + + )} + + {step.label} + + + )} + + )} + +
+ ) +}; + +export const StepListLinks: StepListStory = { + render: (args: any) => ( +
+ + + {(step: (typeof steps)[number]) => ( + + {({isCompleted, stepNumber}) => ( + + {isCompleted ? ( + + ) : ( + + )} + {/* Every step has an href, but only completed steps can be navigated back to. */} + + {step.label} + + + )} + + )} + +
+ ) +}; diff --git a/packages/react-aria-components/test/StepList.test.js b/packages/react-aria-components/test/StepList.test.js new file mode 100644 index 00000000000..af1e0eb3936 --- /dev/null +++ b/packages/react-aria-components/test/StepList.test.js @@ -0,0 +1,296 @@ +/* + * 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 React from 'react'; +import {render} from '@react-spectrum/test-utils-internal'; +import {StepList, StepListContext, StepListItem, StepListItemContent} from '../src/StepList'; + +let steps = [ + {id: 'domain', label: 'Domain'}, + {id: 'dns', label: 'DNS'}, + {id: 'advanced', label: 'Advanced'} +]; + +let renderStepList = (listProps, itemProps) => + render( + + {steps.map(step => ( + + {step.label} + + ))} + + ); + +describe('StepList', () => { + it('should render with default classes', () => { + let {getByRole, getAllByRole} = renderStepList(); + expect(getByRole('list')).toHaveClass('react-aria-StepList'); + + for (let item of getAllByRole('listitem')) { + expect(item).toHaveClass('react-aria-StepListItem'); + } + }); + + it('should render with custom classes', () => { + let {getByRole, getAllByRole} = renderStepList({className: 'steps'}, {className: 'step'}); + expect(getByRole('list')).toHaveClass('steps'); + + for (let item of getAllByRole('listitem')) { + expect(item).toHaveClass('step'); + } + }); + + it('should mark steps before the selected step as completed', () => { + let {getAllByRole} = renderStepList(); + let items = getAllByRole('listitem'); + + expect(items[0]).toHaveAttribute('data-completed'); + expect(items[0]).not.toHaveAttribute('data-current'); + expect(items[0]).not.toHaveAttribute('aria-current'); + + expect(items[1]).toHaveAttribute('data-current'); + expect(items[1]).toHaveAttribute('aria-current', 'step'); + expect(items[1]).not.toHaveAttribute('data-completed'); + + expect(items[2]).not.toHaveAttribute('data-completed'); + expect(items[2]).not.toHaveAttribute('data-current'); + }); + + it('should update completed steps when the selected step changes', () => { + let {getAllByRole, rerender} = renderStepList({selectedKey: 'advanced'}); + let items = getAllByRole('listitem'); + expect(items[0]).toHaveAttribute('data-completed'); + expect(items[1]).toHaveAttribute('data-completed'); + expect(items[2]).toHaveAttribute('data-current'); + + rerender( + + {steps.map(step => ( + + {step.label} + + ))} + + ); + + items = getAllByRole('listitem'); + expect(items[0]).toHaveAttribute('data-current'); + expect(items[1]).not.toHaveAttribute('data-completed'); + expect(items[2]).not.toHaveAttribute('data-completed'); + }); + + it('should mark no steps as completed when the selected step is not in the collection', () => { + let {getAllByRole} = renderStepList({selectedKey: 'nope'}); + + for (let item of getAllByRole('listitem')) { + expect(item).not.toHaveAttribute('data-completed'); + expect(item).not.toHaveAttribute('data-current'); + } + }); + + it('should support dynamic collections', () => { + let {getAllByRole} = render( + + {step => {step.label}} + + ); + + let items = getAllByRole('listitem'); + expect(items.map(item => item.textContent)).toEqual([ + 'CompletedDomain', + 'CurrentDNS', + 'Not completedAdvanced' + ]); + expect(items[0]).toHaveAttribute('data-completed'); + expect(items[1]).toHaveAttribute('data-current'); + }); + + it('should support render props', () => { + let {getAllByRole} = render( + + {step => ( + + {({isCurrent, isCompleted}) => + isCurrent ? 'current' : isCompleted ? 'completed' : step.label + } + + )} + + ); + + expect(getAllByRole('listitem').map(item => item.lastChild.textContent)).toEqual([ + 'completed', + 'current', + 'Advanced' + ]); + }); + + it('should provide a 1-based step number to render props', () => { + let {getAllByRole} = render( + + {() => {({stepNumber}) => `Step ${stepNumber}`}} + + ); + + expect(getAllByRole('listitem').map(item => item.lastChild.textContent)).toEqual([ + 'Step 1', + 'Step 2', + 'Step 3' + ]); + }); + + it('should provide the step number to className and style functions', () => { + let {getAllByRole} = render( + + {step => ( + `step-${stepNumber}`} + style={({stepNumber}) => ({order: stepNumber})}> + {step.label} + + )} + + ); + + let items = getAllByRole('listitem'); + expect(items.map(item => item.className)).toEqual(['step-1', 'step-2', 'step-3']); + expect(items.map(item => item.style.order)).toEqual(['1', '2', '3']); + }); + + it('should support DOM props', () => { + let {getByRole, getAllByRole} = renderStepList( + {'data-foo': 'bar', 'aria-label': 'test list'}, + {'data-bar': 'foo', 'aria-label': 'test item'} + ); + + let list = getByRole('list'); + expect(list).toHaveAttribute('data-foo', 'bar'); + expect(list).toHaveAttribute('aria-label', 'test list'); + + for (let item of getAllByRole('listitem')) { + expect(item).toHaveAttribute('data-bar', 'foo'); + expect(item).toHaveAttribute('aria-label', 'test item'); + } + }); + + it('should not forward the step id to the DOM', () => { + let {getAllByRole} = renderStepList(); + + for (let item of getAllByRole('listitem')) { + expect(item).not.toHaveAttribute('id'); + } + }); + + it('should support custom render function', () => { + let {getByRole, getAllByRole} = renderStepList( + {render: props =>
    }, + {render: props =>
  1. } + ); + + expect(getByRole('list')).toHaveAttribute('data-custom', 'bar'); + for (let item of getAllByRole('listitem')) { + expect(item).toHaveAttribute('data-custom', 'true'); + } + }); + + it('should support refs', () => { + let listRef = React.createRef(); + let itemRef = React.createRef(); + let {getByRole} = render( + + + A + + + ); + + expect(listRef.current).toBe(getByRole('list')); + expect(itemRef.current).toBe(getByRole('listitem')); + }); + + it('should support slots', () => { + let {getByRole} = render( + + + A + + + ); + + let list = getByRole('list'); + expect(list).toHaveAttribute('slot', 'test'); + expect(list).toHaveAttribute('aria-label', 'test'); + }); + + it('should label StepListItemContent with the step state', () => { + let {getByRole} = render( + + + Step A + + + ); + + let step = getByRole('img'); + let [stateId, contentId] = step.getAttribute('aria-labelledby').split(' '); + expect(document.getElementById(stateId)).toHaveTextContent('Current'); + expect(contentId).toBe(step.id); + }); + + it('should render StepListItemContent as a non-focusable div without an href', () => { + let {getByRole} = render( + + + Step A + + + ); + + let content = getByRole('img'); + expect(content.tagName).toBe('DIV'); + expect(content).toHaveAttribute('role', 'img'); + expect(content).not.toHaveAttribute('tabindex'); + expect(content).not.toHaveAttribute('href'); + }); + + it('should render StepListItemContent as an anchor when an href is provided', () => { + let {getAllByRole} = render( + + {step => ( + + {({isCompleted}) => ( + + {step.label} + + )} + + )} + + ); + + let contents = [...getAllByRole('link'), ...getAllByRole('img')]; + expect(contents).toHaveLength(3); + + // Completed step gets the href. + expect(contents[0].tagName).toBe('A'); + expect(contents[0]).toHaveAttribute('href', '#domain'); + expect(contents[0]).not.toHaveAttribute('role'); + + // Current and upcoming steps do not. + for (let content of contents.slice(1)) { + expect(content.tagName).toBe('DIV'); + expect(content).toHaveAttribute('role', 'img'); + expect(content).not.toHaveAttribute('href'); + } + }); +}); From 83487e79d30080aa806e3a1c99da78c6b7daa7d6 Mon Sep 17 00:00:00 2001 From: Rob Snow Date: Fri, 23 Oct 2026 18:12:25 +1100 Subject: [PATCH 4/4] Fix old storybook --- package.json | 3 ++- packages/react-aria-components/src/StepList.tsx | 15 +++++++++++++-- .../react-aria-components/stories/StepList.css | 4 ++-- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 17699d4f2a2..487a2ecca69 100644 --- a/package.json +++ b/package.json @@ -297,7 +297,8 @@ ".storybook-s2/**", "packages/dev/s2-docs/**", "starters/**", - "packages/react-aria-components/stories/Sheet.css" + "packages/react-aria-components/stories/Sheet.css", + "packages/react-aria-components/stories/StepList.css" ] }, "drafts": { diff --git a/packages/react-aria-components/src/StepList.tsx b/packages/react-aria-components/src/StepList.tsx index 35b4371a790..311f106cfc7 100644 --- a/packages/react-aria-components/src/StepList.tsx +++ b/packages/react-aria-components/src/StepList.tsx @@ -38,6 +38,8 @@ 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 {useFocusRing} from 'react-aria/useFocusRing'; +import {useHover} from 'react-aria/useHover'; import {useId} from 'react-aria/useId'; import {useLink} from 'react-aria/useLink'; import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter'; @@ -227,16 +229,25 @@ export const StepListItemContent = /*#__PURE__*/ (forwardRef as forwardRefType)( let elementType = isLink ? 'a' : 'div'; let {linkProps} = useLink({...props, elementType}, objRef); let ElementType = dom[elementType]; + // Only a link is interactive, so a plain `div` never reports hover or focus. + let {hoverProps, isHovered} = useHover({isDisabled: !isLink}); + let {focusProps, isFocusVisible} = useFocusRing(); let DOMProps = filterDOMProps(props, {global: true, labelable: true, isLink}); delete DOMProps.id; return ( + aria-labelledby={`${context?.hiddenId} ${id}`} + data-hovered={isHovered || undefined} + data-focus-visible={isFocusVisible || undefined}> {props.children} ); diff --git a/packages/react-aria-components/stories/StepList.css b/packages/react-aria-components/stories/StepList.css index cbadb9527db..7a1c8ef632c 100644 --- a/packages/react-aria-components/stories/StepList.css +++ b/packages/react-aria-components/stories/StepList.css @@ -67,11 +67,11 @@ color: inherit; text-decoration: none; - &:is(a):hover .label { + &:is(a)[data-hovered] .label { text-decoration: underline; } - &:focus-visible { + &[data-focus-visible] { outline: 2px solid var(--spectrum-alias-focus-ring-color); outline-offset: 2px; }