diff --git a/.changeset/stepper-context-public-surface.md b/.changeset/stepper-context-public-surface.md new file mode 100644 index 0000000000000..a2fd3433b3532 --- /dev/null +++ b/.changeset/stepper-context-public-surface.md @@ -0,0 +1,19 @@ +--- +'@astryxdesign/core': patch +--- + +[component] Deprecate `useStepperContext` and narrow `StepperContextValue` to +the supported subset. Stepper's private coordination — the connector-fill +choreography (`previousActiveStep`) and the dev-mode step registry +(`registerStep`) — is no longer named on the public interface, so changing it +can no longer break consumer types. Both names stay exported until the next +major. + +Custom step composition is not, and was not, supported through this hook: a +Stepper builds its context entirely from the props you passed it, so the hook +returns nothing the call site already lacks, and a hand-rolled step still +cannot draw a correct connector track. Compose with `` and its +`children`, `indicator`, and `endContent` slots, and gate step content on the +same state that drives `activeStep`. + +@alif416 diff --git a/packages/core/src/Stepper/Step.tsx b/packages/core/src/Stepper/Step.tsx index 0772c39c37d92..1a9d3b548b472 100644 --- a/packages/core/src/Stepper/Step.tsx +++ b/packages/core/src/Stepper/Step.tsx @@ -63,7 +63,7 @@ import type {BaseProps} from '../BaseProps'; import {Icon} from '../Icon'; import {VisuallyHidden} from '../VisuallyHidden'; import {useTranslator} from '../i18n'; -import {useStepperContext} from './StepperContext'; +import {useStepperCoordination} from './StepperContext'; import {stepMarker} from './stepper.stylex'; import type {StepStatus} from './StepStatus'; @@ -1017,7 +1017,7 @@ export function Step({ ...rest }: StepProps) { const t = useTranslator(); - const ctx = useStepperContext(); + const ctx = useStepperCoordination(); const { activeStep, previousActiveStep, diff --git a/packages/core/src/Stepper/Stepper.doc.mjs b/packages/core/src/Stepper/Stepper.doc.mjs index ed68e655a0622..4fe6f03e31891 100644 --- a/packages/core/src/Stepper/Stepper.doc.mjs +++ b/packages/core/src/Stepper/Stepper.doc.mjs @@ -46,6 +46,16 @@ export const docs = { description: 'Use status only to apply a semantic color (accent/success/warning/error); pass a custom icon for richer indicators.', }, + { + guidance: true, + description: + "Drive step content from the same state that drives activeStep. The state you pass to is the state a step's content should read — gate children on it directly rather than reading the stepper back from a context.", + }, + { + guidance: false, + description: + 'Call useStepperContext. It is deprecated and will be removed in the next major: a Stepper builds its context from the props you passed it, so the hook returns nothing the call site does not already have. Build custom steps from and its children, indicator, and endContent slots — a hand-rolled step cannot draw a correct connector track.', + }, { guidance: false, description: @@ -255,9 +265,15 @@ export const docsDense = { guidance: true, description: 'Provide onStepClick for non-linear workflows.', }, + { + guidance: true, + description: + 'Gate step children on the same state you pass to activeStep.', + }, { guidance: false, - description: 'Use for fewer than 3 or more than 7 steps.', + description: + 'Use for fewer than 3 or more than 7 steps. Call useStepperContext (deprecated, removed next major — it returns only props you passed; build custom steps from slots).', }, ], }, @@ -321,6 +337,16 @@ export const docsZh = { "当页面已有返回/继续控件时,将 horizontalOptions.collapsedVariant 设为 'withLabel';当周围界面同时提供当前步骤标题和导航、只需要裸进度轨道时,将其设为 'hiddenLabel'。", }, {guidance: true, description: '为非线性工作流程提供 onStepClick。'}, + { + guidance: true, + description: + '用驱动 activeStep 的同一状态来控制步骤内容,而不是从上下文中回读步骤器状态。', + }, + { + guidance: false, + description: + '使用 useStepperContext。它已弃用,将在下一个主版本中移除:步骤器的上下文完全由你传入的 props 构建,该 hook 不会返回调用处尚未拥有的任何内容。请用 及其 children、indicator、endContent 插槽来构建自定义步骤。', + }, {guidance: false, description: '少于3个步骤时使用步骤器。'}, {guidance: false, description: '超过7个步骤时使用步骤器。'}, ], diff --git a/packages/core/src/Stepper/Stepper.public.test.ts b/packages/core/src/Stepper/Stepper.public.test.ts new file mode 100644 index 0000000000000..e74cafcb051ee --- /dev/null +++ b/packages/core/src/Stepper/Stepper.public.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file Stepper.public.test.ts + * @input Imports the Stepper barrel and its context module + * @output Locks the supported public context surface + * @position Compatibility test guarding @astryxdesign/core/Stepper + * + * The decision this file encodes: `useStepperContext` and + * `StepperContextValue` are NOT a supported extension point. Custom step + * composition goes through `` and its slots. Both names stay exported + * until the next major so the removal lands at an explicit compatibility + * boundary, and until then they must not carry Stepper's private + * coordination — see StepperContext.ts. + * + * The `expectTypeOf` assertions here are enforced by `pnpm -F + * @astryxdesign/core typecheck`, not by the test run — a failing one surfaces + * as `TS2554: Expected 2 arguments, but got 1` on the assertion's line, which + * is how vitest reports a type assertion that did not hold. Verified by + * re-widening `StepperContextValue` and watching exactly the matching lines + * fail. + */ + +import {readFile} from 'node:fs/promises'; +import {resolve} from 'node:path'; + +import {describe, expect, expectTypeOf, it} from 'vitest'; + +// Type-only: the runtime surface is checked through a dynamic `import('./index')` +// below, so nothing here is needed as a value. +import type {useStepperContext, StepperContextValue} from './index'; +import type {StepperCoordination} from './StepperContext'; + +describe('Stepper public context surface', () => { + it('keeps private coordination off the public interface', () => { + // The two fields that made an internal change a consumer type break: the + // connector-fill choreography and the dev-mode step registry. Neither is + // something a consumer configures, so neither is named out here. + expectTypeOf().not.toHaveProperty( + 'previousActiveStep', + ); + expectTypeOf().not.toHaveProperty('registerStep'); + + // Guarded at the hook too, which is the declaration a consumer actually + // reads — widening the return type is the way this would regress. + expectTypeOf>().not.toHaveProperty( + 'previousActiveStep', + ); + expectTypeOf>().not.toHaveProperty( + 'registerStep', + ); + }); + + it('still names the fields the deprecation window promises', () => { + // Narrowing removed private coordination, not the supported reads. Anyone + // already calling the hook keeps compiling until the major. + expectTypeOf().toHaveProperty('activeStep'); + expectTypeOf().toHaveProperty('orientation'); + expectTypeOf().toHaveProperty('isNonLinear'); + expectTypeOf().toHaveProperty('onStepClick'); + expectTypeOf().toHaveProperty('density'); + expectTypeOf().toHaveProperty('indicatorPosition'); + + expectTypeOf< + ReturnType +>().toEqualTypeOf(); + }); + + it('carries one value on the wire, widened only inside the module', () => { + // The split is about which declaration a consumer can reach, not about + // building a second object per render. If these ever diverge structurally, + // the provider is handing Step something the public read cannot describe. + expectTypeOf().toMatchTypeOf(); + expectTypeOf().toHaveProperty('previousActiveStep'); + expectTypeOf().toHaveProperty('registerStep'); + }); + + it('closes the internal seam by module boundary, not by naming', async () => { + // An `@internal` tag is a note to a reader; a builder reading an exported + // declaration finds every name on it and can reasonably wire one. So the + // coordination hook and its type must not reach the barrel at all. + const entry = await import('./index'); + expect(Object.keys(entry)).not.toContain('useStepperCoordination'); + expect(Object.keys(entry)).not.toContain('StepperContext'); + + // Checked against the barrel's CODE, with comments stripped: the block + // documenting the deprecation names `StepperCoordination` in prose, and a + // raw substring check cannot tell that from an export. + const source = await readFile(resolve(__dirname, 'index.ts'), 'utf8'); + const code = source + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/\/\/.*$/gm, ''); + expect(code).not.toContain('StepperCoordination'); + expect(code).not.toContain('useStepperCoordination'); + }); + + it('still exports the deprecated names, so removal needs a major', async () => { + // The other half of the contract: this test fails if someone drops them in + // a patch instead of at the compatibility boundary. + const entry = await import('./index'); + expect(Object.keys(entry)).toContain('useStepperContext'); + + const source = await readFile( + resolve(__dirname, 'StepperContext.ts'), + 'utf8', + ); + expect(source).toContain('@deprecated'); + }); +}); diff --git a/packages/core/src/Stepper/Stepper.tsx b/packages/core/src/Stepper/Stepper.tsx index e093711381401..e94ad70ae540b 100644 --- a/packages/core/src/Stepper/Stepper.tsx +++ b/packages/core/src/Stepper/Stepper.tsx @@ -15,6 +15,10 @@ * styling to the frame that contains both the list and compact summary, while * the semantic `
    ` remains the measured ref and DOM pass-through target. * + * The value it publishes is `StepperCoordination`, which is wider than the + * deprecated public `StepperContextValue` — see StepperContext.ts for why the + * two are split. + * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Stepper/Stepper.doc.mjs (props table, features, implementation notes) * - /packages/core/src/Stepper/Stepper.test.tsx (tests for new/changed behavior) @@ -46,7 +50,7 @@ import { StepperContext, type StepperOrientation, type StepperIndicatorPosition, - type StepperContextValue, + type StepperCoordination, } from './StepperContext'; /** @@ -407,7 +411,8 @@ export function Stepper({ const [summarySlot, setSummarySlot] = useState(null); - const ctxValue = useMemo( + const ctxValue = useMemo( + () => ({ activeStep, previousActiveStep, diff --git a/packages/core/src/Stepper/StepperContext.ts b/packages/core/src/Stepper/StepperContext.ts index 13c1475f912b5..9814b2a610985 100644 --- a/packages/core/src/Stepper/StepperContext.ts +++ b/packages/core/src/Stepper/StepperContext.ts @@ -5,12 +5,29 @@ /** * @file StepperContext.ts * @input Uses React createContext/use - * @output Exports StepperContext, useStepperContext, and context types + * @output Exports StepperContext, the internal coordination hook, and the + * deprecated public hook/type * @position Context for Stepper <-> Step communication * + * TWO SHAPES, NOT ONE. + * `StepperCoordination` is what the provider actually carries: everything Step + * needs from Stepper, including the connector-fill choreography and the + * dev-mode index registry. It is deliberately NOT re-exported from `index.ts`, + * so the seam is closed by module boundary rather than by naming convention — + * the same reason `BusyIndicatorLane` lives outside Typeahead's barrel. An + * `@internal` tag is a note to a reader, not a boundary: a builder reading an + * exported declaration finds every name on it and can reasonably wire one, + * pinning a Stepper-internal detail as permanent API. + * + * `StepperContextValue` is the deprecated public subset. It stays exported for + * one compatibility window (see index.ts) and names only fields that were + * already safe to read. Narrowing it is what stops an internal change to the + * fill choreography from landing as a consumer type break. + * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/Stepper/Stepper.doc.mjs * - /packages/core/src/Stepper/index.ts + * - /packages/core/src/Stepper/Stepper.public.test.ts */ import {createContext, use, type RefCallback} from 'react'; @@ -28,8 +45,36 @@ export type StepperDensity = 'compact' | 'balanced' | 'spacious'; */ export type StepperIndicatorPosition = 'separated' | 'on-track'; +/** + * The deprecated public read of a Stepper's context. + * + * Every field here is a value the caller already passed to ``, so + * reading them back buys nothing a prop or the caller's own state does not + * already give — which is why the hook is on its way out rather than being + * grown into a composition API. + * + * @deprecated Not a supported extension point, and scheduled for removal in + * the next major. Thread `activeStep` and the layout props from the state you + * already own; see `useStepperContext`. + */ export interface StepperContextValue { activeStep: number; + orientation: StepperOrientation; + isNonLinear: boolean; + onStepClick: ((index: number) => void) | null; + density: StepperDensity; + indicatorPosition: StepperIndicatorPosition; +} + +/** + * What the provider actually carries. Package-internal: exported from this + * module for Stepper and Step, never from `index.ts`. + * + * Extends the public subset so there is exactly one object on the wire — the + * split is a matter of which declaration a consumer can reach, not of building + * a second value per render. + */ +export interface StepperCoordination extends StepperContextValue { /** * The `activeStep` this stepper last rendered with, so a Step can tell * whether the change it is reacting to was a single step forward — the one @@ -38,16 +83,10 @@ export interface StepperContextValue { * on the first render, which is what keeps a stepper that mounts mid-flow * from animating its way to the step it opened on. * - * Internal: not part of the public API, and deliberately not a Stepper prop. - * When the connector animates is behaviour the stepper owns, not something a - * consumer configures. + * Deliberately not a Stepper prop either. When the connector animates is + * behaviour the stepper owns, not something a consumer configures. */ previousActiveStep: number; - orientation: StepperOrientation; - isNonLinear: boolean; - onStepClick: ((index: number) => void) | null; - density: StepperDensity; - indicatorPosition: StepperIndicatorPosition; /** * Dev-mode index registration and compact-navigation metadata. Each Step * calls this on mount with its `step` index and disabled state. The Stepper @@ -102,16 +141,57 @@ export interface StepperContextValue { minStepWidthMeasureRef: RefCallback; } -export const StepperContext = createContext(null); +export const StepperContext = createContext(null); StepperContext.displayName = 'StepperContext'; -export function useStepperContext(): StepperContextValue { +function useCoordination(hookName: string): StepperCoordination { const ctx = use(StepperContext); if (ctx == null) { throw new Error( - 'useStepperContext must be used within Stepper. ' + - 'Wrap your Step in .', + `${hookName} must be used within Stepper. Wrap your Step in .`, ); } return ctx; } + +/** + * Stepper <-> Step coordination. Package-internal; see the header. + */ +export function useStepperCoordination(): StepperCoordination { + return useCoordination('useStepperCoordination'); +} + +/** + * Reads the enclosing Stepper's configuration. + * + * There is no supported use case for this: a Stepper's context is built + * entirely from props the caller passed in, so anything it returns is already + * in hand at the call site. Custom step composition is not supported through + * it either — the connector fill is choreographed inside `Step`, so a + * hand-rolled step reading this context still cannot draw a correct track. + * Compose with `` and its `children`, `indicator`, and `endContent` + * slots instead. + * + * The returned object is the live coordination value, narrowed on the way out. + * Narrowing the *declaration* is the point — that is the surface a consumer + * reads and builds against — so it is done in the type rather than by copying + * fields into a fresh object on every render of every step. + * + * @deprecated Not a supported extension point, and scheduled for removal in + * the next major. Thread `activeStep` and the layout props from the state you + * already own — the state driving `` is the same state. + * + * @example + * ``` + * // Instead of reading the context from inside a step: + * const [activeStep, setActiveStep] = useState(0); + * + * + * {activeStep === 0 && } + * + * + * ``` + */ +export function useStepperContext(): StepperContextValue { + return useCoordination('useStepperContext'); +} diff --git a/packages/core/src/Stepper/index.ts b/packages/core/src/Stepper/index.ts index 72db7132ecd7d..6db0667cda41c 100644 --- a/packages/core/src/Stepper/index.ts +++ b/packages/core/src/Stepper/index.ts @@ -14,9 +14,25 @@ export type {StepProps, StepIndicatorPreset} from './Step'; export type {StepStatus} from './StepStatus'; -export {useStepperContext} from './StepperContext'; export type { - StepperContextValue, StepperOrientation, StepperIndicatorPosition, } from './StepperContext'; + +// DEPRECATED — removal at the next major. +// +// Neither name is a supported extension point. A Stepper's context is built +// entirely from the props the caller passed it, so the hook hands back only +// what the call site already has; the two fields that were genuinely new to it +// — the connector-fill choreography and the dev-mode step registry — are +// Stepper's own coordination and are no longer named on the public type. They +// live on `StepperCoordination`, which stays inside the module. +// +// Kept exported so the removal lands at an explicit compatibility boundary +// rather than in a patch. Custom step composition is not, and was not, +// supported through this: compose with `` and its `children`, +// `indicator`, and `endContent` slots. +// +// Protected by Stepper.public.test.ts. +export {useStepperContext} from './StepperContext'; +export type {StepperContextValue} from './StepperContext';

    AltStyle によって変換されたページ (->オリジナル) /