{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "steps",
  "title": "步骤条",
  "description": "多步流程的进度与回退导航，支持横排、竖排、错误状态和随容器宽度切换的紧凑形态。",
  "dependencies": [
    "class-variance-authority",
    "lucide-react"
  ],
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "src/registry/ui/steps/steps.tsx",
      "content": "\"use client\"\n\nimport { cva } from \"class-variance-authority\"\nimport { Check } from \"lucide-react\"\n\nimport { buttonVariants } from \"@/components/ui/button\"\nimport { cn } from \"@/lib/utils\"\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\n\nimport { DEFAULT_LABELS } from \"./config\"\nimport type { StepStatus, StepsProps } from \"./types\"\n\nconst stepsVariants = cva(\"@container/steps w-full min-w-0\", {\n  variants: {\n    size: {\n      sm: \"text-xs [--steps-dot:1.25rem]\",\n      default: \"text-sm [--steps-dot:1.5rem]\",\n    },\n    variant: { default: \"\", compact: \"\" },\n  },\n  defaultVariants: { size: \"default\", variant: \"default\" },\n})\n\n/** 多步流程的进度与回退导航；前进与业务校验由调用方处理。 */\nfunction Steps({\n  items,\n  value: valueProp,\n  defaultValue = 0,\n  onValueChange,\n  orientation = \"horizontal\",\n  variant = \"default\",\n  clickable = \"completed\",\n  size = \"default\",\n  labels: labelsProp,\n  className,\n  ...props\n}: StepsProps) {\n  const labels = { ...DEFAULT_LABELS, ...labelsProp }\n  const [rawValue, setValue] = useControllableState({\n    prop: valueProp,\n    defaultProp: defaultValue,\n    onChange: onValueChange,\n  })\n  const value = Number.isFinite(rawValue)\n    ? Math.min(Math.max(Math.trunc(rawValue), 0), Math.max(items.length - 1, 0))\n    : 0\n  const resolvedItems = items.map((item, index) => ({\n    item,\n    status:\n      item.status ??\n      ((index < value\n        ? \"finish\"\n        : index === value\n          ? \"process\"\n          : \"wait\") satisfies StepStatus),\n  }))\n  const current = resolvedItems[value]\n  const statusText = {\n    wait: labels.srWait,\n    process: labels.srProcess,\n    finish: labels.srFinish,\n    error: labels.srError,\n  }\n  const vertical = orientation === \"vertical\"\n  const compact = variant === \"compact\"\n  const auto = orientation === \"auto\" && !compact\n  const summary = labels.compactText\n    .replaceAll(\"{current}\", String(items.length ? value + 1 : 0))\n    .replaceAll(\"{total}\", String(items.length))\n\n  return (\n    <div\n      {...props}\n      data-slot=\"steps\"\n      data-orientation={orientation}\n      data-variant={variant}\n      data-size={size}\n      className={cn(stepsVariants({ size, variant }), className)}\n    >\n      <div\n        data-slot=\"steps-compact\"\n        className={cn(\n          \"min-w-0\",\n          compact\n            ? \"block\"\n            : auto\n              ? \"block @min-[28rem]/steps:hidden\"\n              : \"hidden\",\n        )}\n      >\n        <p\n          className=\"mb-2 flex flex-wrap items-baseline gap-x-1.5 gap-y-1\"\n          aria-live=\"polite\"\n          aria-atomic=\"true\"\n        >\n          <span className=\"text-muted-foreground tabular-nums\">{summary}</span>\n          {current && (\n            <>\n              <span aria-hidden=\"true\" className=\"text-muted-foreground\">\n                ·\n              </span>\n              <span\n                data-slot=\"steps-title\"\n                className={cn(\n                  \"min-w-0 font-medium wrap-anywhere\",\n                  current.status === \"error\" && \"text-destructive\",\n                )}\n              >\n                {current.status === \"error\" && (\n                  <span aria-hidden=\"true\">! </span>\n                )}\n                {current.item.title}\n              </span>\n              <span className=\"sr-only\">{statusText[current.status]}</span>\n            </>\n          )}\n        </p>\n        <ol aria-label={labels.list} className=\"flex list-none gap-1.5 p-0\">\n          {resolvedItems.map(({ item, status }, index) => {\n            const canGoBack =\n              clickable === \"completed\" &&\n              index < value &&\n              status === \"finish\" &&\n              !item.disabled\n            const content = (\n              <>\n                <span className=\"sr-only\">\n                  {index + 1}. {item.title} {item.description} —{\" \"}\n                  {statusText[status]}\n                  {item.disabled ? ` · ${labels.srDisabled}` : \"\"}\n                </span>\n                <span\n                  data-slot=\"steps-progress\"\n                  aria-hidden=\"true\"\n                  className={cn(\n                    \"block h-1 w-full rounded-full\",\n                    status === \"error\"\n                      ? \"bg-destructive\"\n                      : status === \"finish\" || status === \"process\"\n                        ? \"bg-primary\"\n                        : \"bg-muted\",\n                  )}\n                />\n              </>\n            )\n            return (\n              <li\n                key={index}\n                data-slot=\"steps-item\"\n                data-state={status}\n                data-disabled={item.disabled || undefined}\n                aria-current={index === value ? \"step\" : undefined}\n                className=\"min-w-0 flex-1\"\n              >\n                {canGoBack ? (\n                  <button\n                    type=\"button\"\n                    onClick={() => setValue(index)}\n                    className={cn(\n                      buttonVariants({ variant: \"ghost\", size: \"lg\" }),\n                      \"w-full px-0 motion-reduce:transition-none\",\n                    )}\n                  >\n                    {content}\n                  </button>\n                ) : (\n                  <span className=\"flex min-h-9 items-center pointer-coarse:min-h-11\">\n                    {content}\n                  </span>\n                )}\n              </li>\n            )\n          })}\n        </ol>\n      </div>\n      <ol\n        aria-label={labels.list}\n        className={cn(\n          \"m-0 list-none p-0\",\n          vertical ? \"flex-col\" : \"items-start\",\n          compact ? \"hidden\" : auto ? \"hidden @min-[28rem]/steps:flex\" : \"flex\",\n        )}\n      >\n        {resolvedItems.map(({ item, status }, index) => {\n          const canGoBack =\n            clickable === \"completed\" &&\n            index < value &&\n            status === \"finish\" &&\n            !item.disabled\n          const content = (\n            <>\n              <span\n                data-slot=\"steps-indicator\"\n                aria-hidden=\"true\"\n                className={cn(\n                  \"relative z-10 flex size-(--steps-dot) shrink-0 items-center justify-center rounded-full border bg-background font-mono text-xs tabular-nums\",\n                  status === \"finish\" &&\n                    \"border-primary bg-primary text-primary-foreground\",\n                  status === \"process\" &&\n                    \"border-2 border-primary bg-background text-primary\",\n                  status === \"wait\" && \"border-edge text-muted-foreground\",\n                  status === \"error\" && \"border-destructive text-destructive\",\n                )}\n              >\n                {status === \"finish\" ? (\n                  <Check className=\"size-3\" strokeWidth={3} />\n                ) : status === \"error\" ? (\n                  \"!\"\n                ) : (\n                  index + 1\n                )}\n              </span>\n              <span\n                data-slot=\"steps-title\"\n                className={cn(\n                  \"min-w-0 wrap-anywhere whitespace-normal\",\n                  vertical && \"flex-1 text-left\",\n                  status === \"process\" && \"font-medium text-primary\",\n                  status === \"wait\" && \"text-muted-foreground\",\n                  status === \"error\" && \"text-destructive\",\n                )}\n              >\n                {item.title}\n              </span>\n              {item.description != null && (\n                <span\n                  data-slot=\"steps-description\"\n                  className={cn(\n                    \"min-w-0 text-xs font-normal wrap-anywhere whitespace-normal text-muted-foreground\",\n                    vertical && \"ml-auto max-w-1/2 text-right\",\n                  )}\n                >\n                  {item.description}\n                </span>\n              )}\n              <span className=\"sr-only\">\n                {statusText[status]}\n                {item.disabled ? ` · ${labels.srDisabled}` : \"\"}\n              </span>\n            </>\n          )\n          const contentClass = cn(\n            \"relative flex w-full min-w-0 gap-2 rounded-lg px-1.5 py-1.5 text-inherit font-normal\",\n            vertical\n              ? \"min-h-9 flex-row items-center pointer-coarse:min-h-11\"\n              : \"flex-col items-center text-center\",\n          )\n          return (\n            <li\n              key={index}\n              data-slot=\"steps-item\"\n              data-state={status}\n              data-disabled={item.disabled || undefined}\n              aria-current={index === value ? \"step\" : undefined}\n              className={cn(\"relative min-w-0\", !vertical && \"flex-1\")}\n            >\n              {index < items.length - 1 && (\n                <span\n                  data-slot=\"steps-separator\"\n                  aria-hidden=\"true\"\n                  className={cn(\n                    \"pointer-events-none absolute\",\n                    status === \"finish\" ? \"bg-primary\" : \"bg-edge\",\n                    vertical\n                      ? \"top-[calc(.375rem+var(--steps-dot))] bottom-[calc(-.375rem)] left-[calc(.375rem+var(--steps-dot)/2)] w-px\"\n                      : \"top-[calc(.375rem+var(--steps-dot)/2)] left-[calc(50%+var(--steps-dot)/2+.375rem)] h-px w-[calc(100%-var(--steps-dot)-.75rem)]\",\n                  )}\n                />\n              )}\n              {canGoBack ? (\n                <button\n                  type=\"button\"\n                  onClick={() => setValue(index)}\n                  // 导航内容允许多行，覆盖固定高度；触屏点击区仍至少 44px。\n                  className={cn(\n                    buttonVariants({ variant: \"ghost\", size: \"lg\" }),\n                    contentClass,\n                    \"h-auto justify-start whitespace-normal motion-reduce:transition-none pointer-coarse:h-auto pointer-coarse:min-h-11\",\n                  )}\n                >\n                  {content}\n                </button>\n              ) : (\n                <div className={contentClass}>{content}</div>\n              )}\n            </li>\n          )\n        })}\n      </ol>\n    </div>\n  )\n}\n\nexport { Steps, stepsVariants }\nexport type { StepItem, StepStatus, StepsProps } from \"./types\"\nexport type { StepsLabels } from \"./config\"\n",
      "type": "registry:ui",
      "target": "@ui/steps/steps.tsx"
    },
    {
      "path": "src/registry/ui/steps/types.ts",
      "content": "import type * as React from \"react\"\n\nimport type { StepsLabels } from \"./config\"\n\ntype StepStatus = \"wait\" | \"process\" | \"finish\" | \"error\"\n\ntype StepItem = {\n  /** 步骤标题，长标题自动换行 */\n  title: React.ReactNode\n  /** 补充信息，竖排时靠右显示 */\n  description?: React.ReactNode\n  /** 覆盖按当前索引推导的状态 */\n  status?: StepStatus\n  /** 禁止回到这一步；不影响其状态显示。@default false */\n  disabled?: boolean\n}\n\ntype StepsProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"children\" | \"defaultValue\"\n> & {\n  /** 按流程顺序排列的步骤 */\n  items: StepItem[]\n  /** 当前步骤索引，从 0 开始；受控时传入 */\n  value?: number\n  /** 非受控初始索引。@default 0 */\n  defaultValue?: number\n  /** 用户请求回到某一步；受控时由调用方校验后更新 value */\n  onValueChange?: (value: number) => void\n  /** auto 在容器窄于 28rem 时切换 compact。@default \"horizontal\" */\n  orientation?: \"horizontal\" | \"vertical\" | \"auto\"\n  /** compact 显示当前步摘要和分段进度。@default \"default\" */\n  variant?: \"default\" | \"compact\"\n  /** completed 只允许回到当前索引之前且状态为 finish 的步骤。@default \"completed\" */\n  clickable?: \"completed\" | \"none\"\n  /** 圆点与文字尺寸；竖排行最小高 36px，触屏 44px。@default \"default\" */\n  size?: \"sm\" | \"default\"\n  /** 覆盖中文摘要、状态与列表名称 */\n  labels?: Partial<StepsLabels>\n}\n\nexport type { StepItem, StepStatus, StepsProps }\n",
      "type": "registry:ui",
      "target": "@ui/steps/types.ts"
    },
    {
      "path": "src/registry/ui/steps/config.ts",
      "content": "const DEFAULT_LABELS = {\n  /** 支持 {current} 和 {total} 占位符 */\n  compactText: \"第 {current} 步 / 共 {total} 步\",\n  list: \"流程步骤\",\n  srWait: \"未开始\",\n  srProcess: \"进行中\",\n  srFinish: \"已完成\",\n  srError: \"出错\",\n  srDisabled: \"不可返回\",\n}\n\ntype StepsLabels = typeof DEFAULT_LABELS\n\nexport { DEFAULT_LABELS }\nexport type { StepsLabels }\n",
      "type": "registry:ui",
      "target": "@ui/steps/config.ts"
    },
    {
      "path": "src/registry/hooks/use-controllable-state.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\ntype UseControllableStateParams<T> = {\n  /** 受控值；传入后组件不再维护内部状态 */\n  prop?: T\n  /** 非受控时的初始值 */\n  defaultProp: T\n  /** 值变化时回调（受控与非受控都会触发） */\n  onChange?: (value: T) => void\n}\n\n/**\n * 同时支持受控与非受控的状态 hook。\n * 约定见 component-conventions 技能第 4 节：open/defaultOpen/onOpenChange 等成对 props 都用它实现。\n */\nfunction useControllableState<T>({\n  prop,\n  defaultProp,\n  onChange,\n}: UseControllableStateParams<T>) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)\n  const isControlled = prop !== undefined\n  const value = isControlled ? prop : uncontrolled\n\n  // 用 ref 保存最新的回调和值，保证 setValue 引用稳定\n  const onChangeRef = React.useRef(onChange)\n  const valueRef = React.useRef(value)\n  React.useEffect(() => {\n    onChangeRef.current = onChange\n    valueRef.current = value\n  })\n\n  const setValue = React.useCallback(\n    (next: React.SetStateAction<T>) => {\n      const resolved =\n        typeof next === \"function\"\n          ? (next as (prev: T) => T)(valueRef.current)\n          : next\n      if (Object.is(resolved, valueRef.current)) return\n      valueRef.current = resolved\n      if (!isControlled) setUncontrolled(resolved)\n      onChangeRef.current?.(resolved)\n    },\n    [isControlled],\n  )\n\n  return [value, setValue] as const\n}\n\nexport { useControllableState }\n",
      "type": "registry:ui",
      "target": "hooks/use-controllable-state.ts"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)"
    }
  },
  "type": "registry:ui"
}
