跳至正文
组件

Code Stack代码文件堆叠

带行号与语法骨架的代码文件卡,支持扇形、级联、网格排列与悬停散开。

正在加载…

安装

pnpm dlx shadcn@latest add https://yiui.dev/r/code-stack.json
会连带安装 本站条目 grid-layout、flow-diagram。已存在的文件会询问是否覆盖。

用法

CodeStack 负责排列,CodeFile 提供文件窗口、行号、语法骨架与语言角标。卡片可单独使用,不需要客户端 JavaScript。

components/source-files.tsx
import { CodeStack, CodeFile } from "@/components/ui/code-stack/code-stack"

export function SourceFiles() {
  return (
    <CodeStack spread="fan">
      <CodeFile lang="ts" name="index.ts" seed={1} />
      <CodeFile lang="json" name="registry.json" seed={2} active />
      <CodeFile lang="css" name="theme.css" seed={3} />
    </CodeStack>
  )
}

文件按传入顺序排列,active 卡片显示强调色边框并置于上层。建议将激活卡放在序列中间,保留文件名的可见空间。每个直接子节点算作一张卡片,避免用 Fragment 包裹多个文件。

示例

每个示例的标题栏右侧都有「调整」开关:打开后可以实时调整排列与显示上限,并开关悬停散开与激活高亮;未改动的项显示示例自己的取值。调整时预览画框大小不变。

级联

以水平与垂直错位代替旋转,适合配置文件与构建流程的展示。

正在加载…

网格

所有文件平铺,窄屏两列、宽屏四列。文件名和语言角标均可直接读取;卡片高度放不下全部骨架行时,从第一行开始显示,底部裁切。

正在加载…

API

CodeStack

spread 支持 fan / cascade / grid;hoverSpread 默认开启,对扇形与级联生效,网格维持平铺。max 默认 8,超出部分不会渲染;0 输出空容器。扇形和级联默认高度 20rem,可通过 className 调整。

CodeFile

lang 支持 ts / tsx / js / json / css / md / py / go / rs / yaml;其他值仍显示大写角标,颜色回落到主题强调色。name 默认 file.<lang>。seed 决定自动生成的八行骨架,同一个种子在服务端与客户端生成相同结果。

自定义 lines 时,每行含 indent(百分比,0–60)和 tokens 数组;每个片段含 width(百分比,0–100)和可选的 tone。空数组显示没有代码行的文件窗口。

<CodeFile
  lang="json"
  name="registry.json"
  lines={[
    { tokens: [{ width: 18, tone: "muted" }] },
    {
      indent: 10,
      tokens: [
        { width: 24, tone: "primary" },
        { width: 32, tone: "green" },
      ],
    },
    { tokens: [{ width: 12, tone: "muted" }] },
  ]}
/>

调整 Context

CodeStackTuningContext 接收 CodeStackTuning 覆盖项;onReport(report: CodeStackReport) 回报覆盖前的实际属性。 stableFrame 固定调整期间的画框,不改变图形比例。

可访问性

  • 文件名和语言角标是可读取的文本;三点、行号与骨架仅作装饰,读屏不会逐行朗读。
  • 悬停散开只改变装饰布局,不隐藏必要信息,也不添加无意义的 Tab 焦点;内部自带控件获得焦点时同样散开。
  • 系统开启减少动态效果时保留初始排列并关闭过渡;触摸设备保留初始排列。
  • 卡片用于文件示意,不展示实际源码。如需逐一读取完整文件名,使用网格模式。

注意事项

  • 调整面板通过 CodeStackTuningContext 覆盖子树里的 spread、max、hoverSpread、active,并用 onReport 回报示例自己的取值。不包这一层时组件行为不变;它面向调试与文档,正常使用不需要。

  • 扇形与级联的卡片宽度随容器变化,默认最多显示八张,适用于组件封面与空状态插图。

  • 全部颜色来自主题 token 和 --flow-*,后者由安装时带入的 flow-diagram 条目提供。

  • CSS 随组件输出,静态导出与无 JavaScript 环境均可显示并响应悬停。

源码

components/ui/code-stack/code-stack.tsx
"use client"

import * as React from "react"
import { cva } from "class-variance-authority"

import { cn } from "@/lib/utils"
import { flowToneStyle, type FlowTone } from "@/components/ui/flow-diagram/tone"

const LANG_TONES: Record<string, FlowTone> = {
  ts: "blue",
  tsx: "cyan",
  js: "amber",
  json: "primary",
  css: "violet",
  md: "muted",
  py: "green",
  go: "cyan",
  rs: "rose",
  yaml: "amber",
}

/** 文档调整覆盖项;不提供 Provider 时沿用组件属性。 */
type CodeStackReport = {
  spread: CodeStackSpread
  hoverSpread: boolean
  max: number
  active: boolean
}
type CodeStackTuning = Partial<CodeStackReport> & {
  /** 固定调整期间的画框。@default false */
  stableFrame?: boolean
  /** 回报覆盖前的示例取值。 */
  onReport?: (report: CodeStackReport) => void
}
const CodeStackTuningContext = React.createContext<CodeStackTuning>({})

type CodeStackSpread = "fan" | "cascade" | "grid"
type CodeStackProps = React.ComponentProps<"div"> & {
  /** 排列方式。@default "fan" */
  spread?: CodeStackSpread
  /** 悬停或内部控件聚焦时散开。@default true */
  hoverSpread?: boolean
  /** 最多显示的文件数,0 显示空堆叠。@default 8 */
  max?: number
}
type CodeFileLine = {
  indent?: number
  tokens: { width: number; tone?: FlowTone }[]
}
type CodeFileProps = Omit<React.ComponentProps<"div">, "children"> & {
  /** 语言缩写,未知语言使用主题强调色。@default "ts" */
  lang?: string
  name?: string
  /** 每行的缩进与骨架片段,width 是相对于行宽的百分比。 */
  lines?: CodeFileLine[]
  /** 自动骨架的确定性种子。@default 1 */
  seed?: number
  active?: boolean
}

const codeStackVariants = cva(
  "relative isolate w-full [container-type:inline-size]",
  {
    variants: {
      spread: {
        fan: "h-80",
        cascade: "h-80",
        grid: "grid grid-cols-2 gap-4 p-4 sm:grid-cols-4",
      },
    },
    defaultVariants: { spread: "fan" },
  },
)

/** 纯 CSS 排列,不为装饰性悬停增加键盘焦点。 */
function CodeStack({
  className,
  children,
  spread: spreadProp = "fan",
  hoverSpread: hoverSpreadProp = true,
  max: maxProp = 8,
  ...props
}: CodeStackProps) {
  const tuning = React.useContext(CodeStackTuningContext)
  const { onReport } = tuning
  const spread = tuning.spread ?? spreadProp
  const hoverSpread = tuning.hoverSpread ?? hoverSpreadProp
  const max = tuning.max ?? maxProp
  React.useEffect(() => {
    onReport?.({
      spread: spreadProp,
      hoverSpread: hoverSpreadProp,
      max: maxProp,
      active: React.Children.toArray(children).some(
        (child) =>
          React.isValidElement<CodeFileProps>(child) &&
          Boolean(child.props.active),
      ),
    })
  }, [onReport, spreadProp, hoverSpreadProp, maxProp, children])

  const frame = React.useRef<HTMLDivElement>(null)
  React.useLayoutEffect(() => {
    const element = frame.current
    if (!element || !tuning.stableFrame) return
    // 保留示例原始高度;网格溢出时在画框内滚动,不挤压卡片。
    element.style.height = element.getBoundingClientRect().height + "px"
    return () => {
      element.style.height = ""
    }
  }, [tuning.stableFrame])
  const files = React.Children.toArray(children).slice(
    0,
    Math.max(0, Math.floor(max)),
  )
  return (
    <div
      ref={frame}
      data-slot="code-stack"
      data-spread={spread}
      data-hover-spread={hoverSpread || undefined}
      className={cn(
        codeStackVariants({ spread }),
        tuning.stableFrame && "content-start overflow-auto",
        className,
      )}
      {...props}
    >
      <style href="code-stack-layout" precedence="default">{`
        [data-slot="code-stack"] > [data-slot="code-stack-position"] { transition: transform 280ms cubic-bezier(.2,.8,.2,1); }
        [data-slot="code-stack"][data-spread="fan"] > [data-slot="code-stack-position"] {
          position: absolute; top: 50%; left: 50%; width: clamp(6.5rem, 25cqw, 10rem);
          transform: translate(calc(-50% + var(--code-offset) * min(7cqw, 2.5rem)), calc(-48% + var(--code-curve) * 4px)) rotate(calc(var(--code-offset) * 9deg));
        }
        [data-slot="code-stack"][data-spread="cascade"] > [data-slot="code-stack-position"] {
          position: absolute; top: 50%; left: 50%; width: clamp(7rem, 30cqw, 11rem);
          transform: translate(calc(-50% + var(--code-offset) * min(7cqw, 2.2rem)), calc(-50% + var(--code-offset) * 20px));
        }
        @media (hover: hover) and (prefers-reduced-motion: no-preference) {
          [data-slot="code-stack"][data-hover-spread][data-spread="fan"]:hover > [data-slot="code-stack-position"],
          [data-slot="code-stack"][data-hover-spread][data-spread="fan"]:focus-within > [data-slot="code-stack-position"] {
            transform: translate(calc(-50% + var(--code-offset) * min(9cqw, 3.2rem)), calc(-55% + var(--code-curve) * 5px)) rotate(calc(var(--code-offset) * 13deg));
          }
          [data-slot="code-stack"][data-hover-spread][data-spread="cascade"]:hover > [data-slot="code-stack-position"],
          [data-slot="code-stack"][data-hover-spread][data-spread="cascade"]:focus-within > [data-slot="code-stack-position"] {
            transform: translate(calc(-50% + var(--code-offset) * min(10cqw, 3rem)), calc(-50% + var(--code-offset) * 28px));
          }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-slot="code-stack"] > [data-slot="code-stack-position"] { transition: none; }
        }
      `}</style>
      {files.map((file, index) => {
        const offset = index - (files.length - 1) / 2
        return (
          <div
            key={React.isValidElement(file) ? (file.key ?? index) : index}
            data-slot="code-stack-position"
            className="relative min-w-0 has-data-active:z-20"
            style={
              {
                "--code-offset": offset,
                "--code-curve": offset * offset,
              } as React.CSSProperties
            }
          >
            {file}
          </div>
        )
      })}
    </div>
  )
}

function skeleton(seed: number): CodeFileLine[] {
  let value = seed >>> 0
  const random = () => {
    value = (Math.imul(value, 1664525) + 1013904223) >>> 0
    return value / 4294967296
  }
  return Array.from({ length: 8 }, (_, index) => ({
    indent: index === 0 || index === 7 ? 0 : 8,
    tokens: Array.from(
      { length: 2 + Math.floor(random() * 2) },
      (_, token) => ({
        width: 10 + Math.floor(random() * 16),
        tone: token === 0 ? "muted" : token === 1 ? undefined : "green",
      }),
    ),
  }))
}

/** 代码的示意卡片;骨架不是实际源码,文件名仍然能被读屏读取。 */
function CodeFile({
  className,
  lang = "ts",
  name,
  lines,
  seed = 1,
  active = false,
  style,
  ...props
}: CodeFileProps) {
  const tuning = React.useContext(CodeStackTuningContext)
  active = tuning.active ?? active
  const language = lang.toLowerCase()
  const rows = lines ?? skeleton(seed)
  return (
    <div
      data-slot="code-file"
      data-active={active || undefined}
      className={cn(
        "relative flex aspect-[4/5] w-full min-w-0 flex-col overflow-hidden rounded-lg border border-edge bg-card text-card-foreground shadow-sm data-active:border-primary/60 data-active:ring-2 data-active:ring-primary/10",
        className,
      )}
      style={{ ...flowToneStyle(LANG_TONES[language] ?? "primary"), ...style }}
      {...props}
    >
      <div
        data-slot="code-file-header"
        className="flex min-w-0 flex-wrap items-center gap-1 border-b border-edge px-3 py-2.5"
      >
        <span
          aria-hidden="true"
          className="size-1.5 shrink-0 rounded-full bg-(--flow-rose)"
        />
        <span
          aria-hidden="true"
          className="size-1.5 shrink-0 rounded-full bg-(--flow-amber)"
        />
        <span
          aria-hidden="true"
          className="size-1.5 shrink-0 rounded-full bg-(--flow-green)"
        />
        <span
          className="mt-1 basis-full truncate font-mono text-[9px] text-muted-foreground"
          title={name}
        >
          {name ?? `file.${lang}`}
        </span>
      </div>
      <div
        data-slot="code-file-lines"
        aria-hidden="true"
        className="flex min-h-0 flex-1 flex-col justify-center-safe gap-2 overflow-hidden px-3 py-3"
      >
        {rows.map((line, index) => (
          <div key={index} className="flex h-2 shrink-0 items-center gap-2">
            <span className="w-2 shrink-0 text-right font-mono text-[8px] text-muted-foreground">
              {index + 1}
            </span>
            <div
              className="flex min-w-0 flex-1 items-center gap-1"
              style={{
                paddingLeft: `${Math.max(0, Math.min(60, line.indent ?? 0))}%`,
              }}
            >
              {line.tokens.map((token, part) => (
                <span
                  key={part}
                  className="h-1 shrink-0 rounded-full bg-(--flow-tone)/45"
                  style={{
                    width: `${Math.max(0, Math.min(100, token.width))}%`,
                    ...(token.tone ? flowToneStyle(token.tone) : {}),
                  }}
                />
              ))}
            </div>
          </div>
        ))}
      </div>
      <div
        data-slot="code-file-footer"
        className="flex items-end justify-between gap-2 px-3 pb-3"
      >
        <span
          aria-hidden="true"
          className="font-mono text-[10px] text-muted-foreground/60"
        >
          {"</>"}
        </span>
        <span className="rounded-md border border-edge bg-(--flow-tone)/10 px-1.5 py-0.5 font-mono text-[10px] font-semibold text-(--flow-tone)">
          {language.toUpperCase()}
        </span>
      </div>
    </div>
  )
}

export { CodeStackTuningContext, CodeStack, CodeFile, codeStackVariants }
export type {
  CodeStackTuning,
  CodeStackReport,
  CodeStackProps,
  CodeStackSpread,
  CodeFileProps,
  CodeFileLine,
}