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/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..311f106cfc7 --- /dev/null +++ b/packages/react-aria-components/src/StepList.tsx @@ -0,0 +1,255 @@ +/* + * 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, + 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 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'; +import {useObjectRef} from 'react-aria/useObjectRef'; + +export interface StepListProps + extends + Omit, 'disabledKeys'>, + 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; + /** + * 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. + */ + selectedKey: Key; +} + +export const StepListContext = + createContext, HTMLOListElement>>(null); + +interface StepListState { + /** 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); + +/** + * 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 {CollectionRoot} = useContext(CollectionRendererContext); + let {selectedKey} = props; + let DOMProps = filterDOMProps(props, {global: true, labelable: true}); + + return ( + }> + {collection => ( + + + + + + )} + + ); +}); + +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; + /** The position of the step within the `StepList`, starting from 1. */ + stepNumber: number; +} + +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 `selectedKey` of the `StepList`. */ + id?: Key; +} + +class StepListItemNode extends CollectionNode { + static readonly type = 'item'; +} + +/** + * A StepListItem represents an individual step within a ``. + */ +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} + + + ); + } +); + +export interface StepListItemContentProps + extends + DOMProps, + LinkDOMProps, + AriaLabelingProps, + Omit, 'onClick'> {} + +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 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]; + // 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 ( + + {props.children} + + ); + } +); 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/StepList.css b/packages/react-aria-components/stories/StepList.css new file mode 100644 index 00000000000..7a1c8ef632c --- /dev/null +++ b/packages/react-aria-components/stories/StepList.css @@ -0,0 +1,78 @@ +.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; + } +} + +.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)[data-hovered] .label { + text-decoration: underline; + } + + &[data-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 new file mode 100644 index 00000000000..bfb04816d4a --- /dev/null +++ b/packages/react-aria-components/stories/StepList.stories.tsx @@ -0,0 +1,137 @@ +/* + * 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', 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: { + selectedKey: 'dns' + }, + argTypes: { + selectedKey: { + 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 => ( + + {({isCompleted, stepNumber}) => ( + + {isCompleted ? ( + + ) : ( + + )} + + {step.label} + + + )} + + ))} + +
+ ) +}; + +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/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} + + + + ))} + + +
+ ) +}; 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'); + } + }); +});