// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Step.tsx * @input Uses React, stylex, theme tokens (including the motion duration token * the connector fill animates on), StepperContext, and per-step theme selector * state * @output Exports Step component and StepProps * @position Individual step item; used inside Stepper * * Every connector this file draws — the separated progress bars and the * on-track segments alike — paints its accent fill as a scaled ::before rather * than by swapping a background color, so a step the flow advances into grows * its line along the track. See the CONNECTOR FILL block in the styles below * for the axis/RTL/reduced-motion rules that go with it. * * Exactly one change animates: advancing a single step. Going back, jumping * forward by more than one, and mounting mid-flow all land at once. The rule * and the reasoning behind it sit above the styles; the arithmetic that keeps * the one animated span reading as a single front — an on-track span is drawn * by two steps, three where a content slot splits it — is in the component. * Nothing about it is public API: Stepper hands each Step the step it came * from through context, and the rest is derived. In a compact horizontal * Stepper, each track node is presentational rather than clickable; navigation * moves to the named previous/next controls in the summary row. The on-track * layout keeps each indicator on the rail and does not repeat the active one * beside the summary label. Each public content slot stays mounted and is * hidden to preserve local state. * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Stepper/Stepper.doc.mjs * - /packages/core/src/Stepper/Stepper.test.tsx * - /packages/core/src/Stepper/index.ts * - /apps/storybook/stories/Stepper.stories.tsx * - /packages/cli/assets/templates/blocks/components/Stepper/ (showcase blocks) * - /packages/cli/assets/templates/blocks/components/Step/ (showcase blocks) */ import { useLayoutEffect, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, radiusVars, fontWeightVars, typeScaleVars, textSizeVars, durationVars, easeVars } from "../theme/tokens.stylex.js"; import { focusOutlineStyles, mergeProps, isRenderable, themeProps } from "../utils/index.js"; import { interactionOverlayStyles } from "../utils/interactionOverlay.stylex.js"; import type { BaseProps } from "../BaseProps.js"; import { Icon } from "../Icon/index.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { useTranslator } from "../i18n/index.js"; import { useStepperInternalContext } from "./StepperContext.js"; import "./stepper.stylex.js"; import { stepMarker } from "./stepper.stylex.js"; import type { StepStatus } from "./StepStatus.js"; /** * Built-in indicator presets. Anything other than these strings passed to * `indicator` is treated as a custom ReactNode (e.g. an ``). * - 'auto': numbered badge for not-yet-reached steps, a check once completed * (default) * - 'number': always a numbered badge * - 'none': no indicator — just the progress bar and label */ export type StepIndicatorPreset = 'auto' | 'number' | 'none'; export type StepDensity = 'compact' | 'balanced' | 'spacious'; export interface StepProps extends BaseProps { /** Ref forwarded to the root element */ ref?: React.Ref; /** * Zero-based index of this step. Used to derive progress (completed / * active / not-started) relative to the parent's `activeStep`. */ step: number; /** * Step label text. */ label: string; /** * Optional description shown below the label. */ description?: string; /** * Content rendered below the label and description. Useful in vertical * steppers to show form fields or detailed content for each step. Compact * horizontal steppers hide it without unmounting it, preserving local state. */ children?: ReactNode; /** * Semantic status for the step, mapped to the global Astryx semantic tokens * (`accent`, `success`, `warning`, `error`). In the default `auto` indicator * mode it sets both the indicator color and a matching glyph: `success` shows * a green check-circle, `warning`/`error` show the shared Input status icons. * `accent` is color-only. The current (in-progress) step always keeps its * current-step indicator regardless of `status`. Never recolors the * connector/track. * * Because the indicator glyphs are decorative (aria-hidden), the status also * reaches assistive technology as text: visually hidden "completed" / * "warning" / "error" next to the label, and composed into the accessible * name of clickable steps. */ status?: StepStatus; /** * Disable interaction for this step. * @default false */ isDisabled?: boolean; /** * Marks the step as optional, appending an "Optional" affordance after the * label. * @default false */ isOptional?: boolean; /** * Trailing content rendered at the end of the label row (e.g. a timestamp * or status chip). */ endContent?: ReactNode; /** * What to show as the step indicator. Accepts a preset string or any * ReactNode: * - 'auto': numbered badge until completed, then a check (default) * - 'number': always a numbered badge * - 'none': no indicator, just the bar + label * - ReactNode: any custom icon or element to render as the indicator * @default 'auto' */ indicator?: StepIndicatorPreset | ReactNode; /** * Controls vertical padding of the step. Falls back to the stepper-level * density when unset. * - 'compact': minimal padding (4px block) * - 'balanced': default (8px block) * - 'spacious': generous (12px block, 12px inline) */ density?: StepDensity; } // --- Default progress icons (16px) --- // // The `completed` progress state and the current-step ring are drawn as local // glyphs rather than sourced from the Icon registry. This is a deliberate // exception to the "glyphs come from the registry" convention (audit rule T17), // for two reasons: // // 1. `CurrentIcon` (a dot inside a ring) has no registry equivalent — it is a // progress affordance, not a general-purpose icon. // 2. `CheckCircleIcon` (a filled circle with a check) is intentionally NOT the // registry `success` glyph, even though the two look similar. They mean // different things and must stay independently restyleable: `completed` // marks *progress through the sequence* (every step you've passed), while // the semantic `success` status marks a step's *outcome*. A stepper can // show a completed step that also carries a `warning`/`error` status, so // collapsing the two glyphs would conflate progress with outcome. The // semantic `success`/`warning`/`error` STATUS glyphs DO defer to the themed // registry (see the status branch below), so those share one visual // language; only the progress marks are local. // // Both glyphs paint via `currentColor`, so the indicator's color is still // controlled by tokens on the wrapper (never hardcoded), and the wrapper // carries the `astryx-step-indicator` theme target. /** Filled circle with a check — shown for a completed step in 'auto' mode. */ function CheckCircleIcon() { return ; } /** Filled dot in a ring — shown for the active step in 'auto' mode. */ function CurrentIcon() { return ; } // --- Styles --- const BAR_WIDTH = spacingVars['--spacing-1']; /** * How much of itself a connector gives up where it meets the indicator, read * from the public `--step-connector-gap` declared on the Stepper root. * * Clipped, not padded. The gap has to apply to two layers — the track, which is * the segment's own background, and the accent fill, which is an absolutely * placed `::before`. Spending it on each separately meant two declarations on * two boxes: a percentage then resolved against a different containing block * for each and stopped them ~1.2px apart. `clip-path` is ONE declaration on the * segment that clips the element and its pseudo-element together, against one * reference box — so every accepted value behaves the same way on both layers, * which is what a public input owes its full value domain. * * Clipping also cannot change layout: the segment keeps its box, so the node it * positions cannot move and the Stepper cannot grow. A raw `1rem` padding grew * a 180px stepper to 240px. * * Still clamped, because the value arrives with nothing in between to reject * it. Both halves earn their place, and not for the reasons padding needed: * * - `max(0px, …)` — `inset()` ACCEPTS a negative length. Chromium computes * `inset(0 0 -4px 0)` as-is rather than dropping it, which is the opposite * of padding (there CSS clamps a negative to `0` for you). This normalises * it to `0` so a negative gap means "no gap" rather than a clip rect * stretched outside the segment's own box. * - `min(…, --spacing-2)` — the flexible segment's own `min-height`, so an * oversized gap leaves a short track rather than an unbounded one. * * The Stepper root explicitly inherits the property, so a horizontal Stepper * can receive a consumer value (or its 0px default) from the styled frame while * a theme can still set the property directly on the `stepper` target. The * use-site fallback supplies the same default to an unframed vertical Stepper. */ const CONNECTOR_GAP = `max(0px, min(var(--step-connector-gap, 0px), ${spacingVars['--spacing-2']}))`; // Every indicator — check, ring, custom icon, number badge — occupies the same // box, so the `auto` mode swapping a number for a check as a step completes // never nudges the label or the track. const INDICATOR_SIZE = spacingVars['--spacing-4']; // Indicator (16px) + the label-row gap (8px). The indent that lines a // description or a content slot up with the label above it. const INDICATOR_GUTTER = spacingVars['--spacing-6']; // --- Connector fill motion --- // // One change animates: advancing exactly one step. Going back, jumping // forward by more than one, and mounting mid-flow all apply at once. // // Animating the others was tried and read worse than not animating them. A // retreat is the clearest case: run forward, the transition ends with a bar // that is nearly full, and nearly full is indistinguishable from full, so the // eye calls it arrived well before it settles. Run backward, the same curve // ends with a shrinking stub of accent still on the track, and a remnant that // is still there reads unmistakably as not finished. Identical arithmetic in // both directions — measurably so — and the backward one still feels slow, // because the tail is what is visible rather than what is hidden. No easing // fixes that; only not animating it does. A multi-step jump fails differently: // it is a navigation rather than a progression, and sweeping a front across // four segments makes the user sit through a journey they asked to skip. // // What is left is the one gesture where the movement carries the meaning: the // flow went forward one step, and the line grows to show it. That span takes // the medium band — a fill crossing a whole segment is a spatial change, not a // micro-interaction, so per `astryx docs motion` it sits above the fast band // reserved for high-frequency states like hover. const FILL_SPAN_TIME = durationVars['--duration-medium']; // Share of one node-to-node span owned by the segment *arriving* at the far // node; the segment leaving the near node takes the rest. On-track spans are // drawn by two steps (see the ON-TRACK section), and the time has to be split // the way the length is or the two halves sweep at visibly different speeds. // // Horizontal: both halves are `flex: 1` inside equal-width steps, so an even // split is the proportional one. // // Vertical: the halves are nothing like equal, and no constant can be right // for all of them. The arriving half is otSegLeadV, a cap fixed at one density // space; the leaving half is otSegFlexV, grown to whatever is left of the step // row, so its length turns on label and description lengths this file cannot // see. Measured in the browser the cap is 40% of a plain balanced span (8 of // 20px) but 21% once the step carries a description (8 of 38px). 0.3 is the // value that minimises the worse of those two: it leaves the cap ~1.6x off the // rail's speed either way, where tuning to one case puts the other past 2x. // Matching each case exactly would mean measuring the rendered rail, a layout // read this system avoids. const OT_ARRIVAL_SHARE_H = 0.5; const OT_ARRIVAL_SHARE_V = 0.3; // A vertical step carrying a content slot draws a third segment down the side // of it (otContentSegV) which continues the leaving half past the row, so the // leaving half's time splits again. A slot runs several times the rail left // above it — 34px of rail to 108px of slot in the spacious content story — and // this is the middle of that range. Matching it exactly would mean measuring // the rendered slot; the ordering is what keeps the front contiguous, and this // keeps its speed in the right neighbourhood. const OT_RAIL_SHARE_OF_LEAVING = 0.25; /** Scale a CSS `; } else { // Priority: explicit custom icon → status glyph (non-current steps) → // progress-derived default (check when completed, ring when current). const iconContent: ReactNode = customIcon != null ? customIcon : statusGlyph === 'success' ? : statusGlyph === 'warning' ? : statusGlyph === 'error' ? : progress === 'completed' ? : ; // Wrapper tint drives the currentColor glyphs (check-circle / ring / // custom icon). The status glyphs set their own color, but we tint // the wrapper to match so the indicator's color reflects `status` too. const iconColorStyle = isDisabled ? styles.iconDisabled : customIcon != null ? status === 'accent' ? styles.iconAccent : status === 'success' ? styles.iconSuccess : status === 'warning' ? styles.iconWarning : status === 'error' ? styles.iconError : progress === 'completed' ? styles.iconCompleted : progress === 'in-progress' ? styles.iconInProgress : styles.iconNotStarted : statusGlyph === 'success' ? styles.iconSuccess : statusGlyph === 'warning' ? styles.iconWarning : statusGlyph === 'error' ? styles.iconError : progress === 'completed' ? styles.iconCompleted : progress === 'in-progress' ? styles.iconInProgress : styles.iconNotStarted; indicatorNode = ; } } // Every indicator glyph above is aria-hidden (pure decoration), so the // step's progress/status must also reach assistive tech as text // (WCAG 1.4.1 / 1.3.1). `error`/`warning` announce the semantic status; // `success` and a completed step both announce "completed". The current // step is announced via aria-current="step" instead, and not-started steps // stay silent (the default state needs no qualifier). const statusText: string | null = status === 'error' ? t('@astryx.step.status.error') : status === 'warning' ? t('@astryx.step.status.warning') : status === 'success' || progress === 'completed' ? t('@astryx.step.status.completed') : null; // Rendered next to the label: hidden text for static steps, and composed // into the button's accessible name for clickable ones (an aria-label on // the button would otherwise override the hidden text). Two separate keys // rather than string concatenation so translations control the joiner. const statusTextNode = isRenderable(statusText) ? {statusText} : null; const stepAriaLabel = statusText != null ? t('@astryx.step.goToStepWithStatus', { stepNumber: step + 1, label, status: statusText }) : t('@astryx.step.goToStep', { stepNumber: step + 1, label }); const hasIndicator = indicator !== 'none'; const labelColorStyle = isDisabled ? styles.labelDisabled : progress === 'not-started' ? styles.labelNotStarted : isActive ? styles.labelInProgress : undefined; // Both text parts carry {progress, status}, the phase vocabulary of // `step-indicator` above. The label additionally owns Stepper's disabled // paint, so it alone carries disabled below. const labelThemeProps = themeProps('step-label', { progress, status: status ?? undefined, // The label owns Stepper's disabled paint (`styles.labelDisabled` above), // so it owns the selector too. The description does not change under // disabled and deliberately keeps the smaller {progress, status} surface. disabled: isDisabled ? 'disabled' : null }); const descriptionThemeProps = themeProps('step-description', { progress, status: status ?? undefined }); // Indicator + Label row. In compact on-track mode the indicator remains // visible on the rail, so the summary reuses this row without drawing the // active indicator a second time beside the label. const iconLabelNode =
{(!isCompact || indicatorPosition !== 'on-track') && indicatorNode} {label} {statusTextNode} {isOptional && <> • {t('@astryx.step.optional')} } {endContent}
; const descriptionNode = isRenderable(description) ?
{description}
: null; // Compact horizontal layouts hide public step content without unmounting it, // so local state survives as the container crosses the threshold. const contentNode = isRenderable(children) ? : null; // Theme data attributes reflect progress + optional semantic status. const stepThemeProps = themeProps('step', { progress, status: status ?? undefined }); // ======= SUMMARY: this step, drawn below a collapsed track ======= // // Only the current step draws one, into a row the stepper owns and keeps // outside the `
    ` — so the list is left with exactly the children it was // given, and the on-track connectors go on reading their own // `:first-child`/`:last-child` position to decide which caps to hide. // // Rendered here rather than handed up as data because it reuses // `iconLabelNode`: indicator resolution, status glyphs, completed checks, // disabled tints, and the compact on-track rule above stay in one place. The // description is re-wrapped, though — the row version indents itself under // an indicator, and centred under a track there is nothing to line it up with. const summaryNode = isCompact && isActive && summarySlot != null ? createPortal(
    {iconLabelNode} {isRenderable(description) ? {description} : null}
    , summarySlot) : null; // Every compact step is presentational, including on-track indicators, so // each item restores its label and status as hidden text. The list stays a // complete ordered sequence with `aria-current` intact, while the summary row // below it owns the compact navigation controls and remains decorative apart // from those buttons. const compactNameNode = isCompact ? <> {label} {statusTextNode} : null; // ======= ON-TRACK: indicator is a node on the connector ======= if (indicatorPosition === 'on-track') { // Connector fill is purely progress-based (matches the separated bar): // the segment before the indicator is "reached" once we're at/past this // step; the segment after is filled only once this step is completed. // `status` never recolors the connector — only the indicator. const beforeFilled = step <= activeStep; const afterFilled = step < activeStep; // Both segments animate their fill along the track (see connectorTrack), so // the pair is picked by orientation: the vertical column scales down its // block axis, the horizontal row along the reading direction. const segFilledStyle = isVertical ? styles.connectorFillV : styles.connectorFillH; const segEmptyStyle = isVertical ? styles.connectorEmptyV : styles.connectorEmptyH; const beforeSegStyle = beforeFilled ? segFilledStyle : segEmptyStyle; const afterSegStyle = afterFilled ? segFilledStyle : segEmptyStyle; // First/last connector visibility is decided structurally from the step's // own
  1. position (see otSegHiddenIfFirst/Last), not by counting children // in the parent — so grouping steps in a fragment can't break it. // A node-to-node span is drawn by two steps: this one's trailing segments // leave its own node, the next one's leading cap arrives at the next node. // Both flip on the same condition, so left to themselves they fill together // and one gap between two nodes reads as two dashes converging on the space // between them. Handing each its own slice of the span's time, in track // order — the leaving half first, then the arriving one — is what closes // that into a single line growing. const arrivalShare = isVertical ? OT_ARRIVAL_SHARE_V : OT_ARRIVAL_SHARE_H; const leavingShare = 1 - arrivalShare; // A content slot's segment continues the leaving half below the row, so the // two divide the leaving time between them; with no slot the rail segment // takes all of it. Each step can work this out for its own trailing side // alone: the arriving cap always takes the span's last `arrivalShare`, so // the step drawing it never has to know whether the step before it carries // a slot. const hasContentSeg = isVertical && isRenderable(children); const railShare = leavingShare * (hasContentSeg ? OT_RAIL_SHARE_OF_LEAVING : 1); const contentShare = leavingShare - railShare; const beforeTiming = fillTiming(step - 1, leavingShare, arrivalShare); const railTiming = fillTiming(step, 0, railShare); const contentTiming = fillTiming(step, railShare, contentShare); const densitySpace = density === 'compact' ? spacingVars['--spacing-1'] : density === 'spacious' ? spacingVars['--spacing-3'] : spacingVars['--spacing-2']; const labelLineNode =
    {label} {statusTextNode} {isOptional && <> • {t('@astryx.step.optional')} } {endContent}
    ; const otDescriptionNode = isRenderable(description) ? {description} : null; const otContentNode = !isRenderable(children) ? null : isVertical ?
    : ; if (isVertical) { const inner = <>
  2. {isClickable ? :
    {inner}
    } {otContentNode}
  3. ; } // Horizontal on-track const innerH = <>