wui
组件

描述列表 Descriptions

以清晰对齐的键值结构展示对象、记录与档案详情,支持跨列合并、边框网格与上下/左右排版。

基础用法

最基础的描述列表用法。使用 Descriptions 容器配合 DescriptionsItem 声明标签与对应内容;默认无边框样式下标签与标题左对齐,适合嵌入详情页正文:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/descriptions
安装基础依赖与工具函数
pnpm add clsx tailwind-merge
复制组件源码到 components/ui/descriptions.tsx
components/ui/descriptions.tsx
import * as React from "react"

import { cn } from "@/lib/utils"

export interface DescriptionsItemProps extends Omit<
  React.ComponentProps<"td">,
  "colSpan"
> {
  /** Term associated with the item value. */
  label: React.ReactNode
  /** Number of description columns occupied by this item. @default 1 */
  span?: 1 | 2 | 3 | 4
  /** Classes applied to the label cell. */
  labelClassName?: string
  /** Classes applied to the value cell. */
  contentClassName?: string
}

/** Declares one label-value pair inside Descriptions. */
function DescriptionsItem(_props: DescriptionsItemProps) {
  return null
}

DescriptionsItem.displayName = "DescriptionsItem"

export interface DescriptionsProps extends Omit<
  React.ComponentProps<"div">,
  "title"
> {
  /** Heading rendered above the description list. */
  title?: React.ReactNode
  /** Action rendered opposite the heading. */
  extra?: React.ReactNode
  /** Number of item groups per row. @default 3 */
  columns?: 1 | 2 | 3 | 4
  /** Draw a structured border around labels and values. @default false */
  bordered?: boolean
  /** Place labels beside or above their values. @default "horizontal" */
  layout?: "horizontal" | "vertical"
  /** Vertical density. @default "default" */
  size?: "sm" | "default"
  /** Fixed width of every horizontal label cell. @default "7rem" */
  labelWidth?: React.CSSProperties["width"]
}

type DescriptionItemElement = React.ReactElement<DescriptionsItemProps>

type DescriptionCell = { item: DescriptionItemElement; span: number }

/** Packs items into rows; the last item of an incomplete row stretches to fill it. */
function groupItems(children: React.ReactNode, columns: number) {
  const items = React.Children.toArray(children).filter(
    React.isValidElement
  ) as DescriptionItemElement[]
  const rows: DescriptionCell[][] = []
  let row: DescriptionCell[] = []
  let occupied = 0

  const flush = () => {
    const last = row.at(-1)
    if (last) last.span += columns - occupied
    rows.push(row)
    row = []
    occupied = 0
  }

  items.forEach((item) => {
    const span = Math.min(item.props.span ?? 1, columns)
    if (occupied > 0 && occupied + span > columns) flush()
    row.push({ item, span })
    occupied += span
    if (occupied === columns) flush()
  })

  if (row.length) flush()
  return rows
}

/** A table-aligned semantic key-value list for records, profiles, and object summaries. */
function Descriptions({
  className,
  children,
  title,
  extra,
  columns = 3,
  bordered = false,
  layout = "horizontal",
  size = "default",
  labelWidth = "7rem",
  ...props
}: DescriptionsProps) {
  const rows = groupItems(children, columns)
  const horizontal = layout === "horizontal"
  const cellPadding = size === "sm" ? "px-3 py-2" : "px-4 py-3"
  const compactPadding = size === "sm" ? "py-1" : "py-2"
  const normalizedLabelWidth =
    typeof labelWidth === "number" ? `${labelWidth}px` : labelWidth

  return (
    <div
      data-slot="descriptions"
      data-bordered={bordered || undefined}
      data-layout={layout}
      data-size={size}
      className={cn("min-w-0", className)}
      {...props}
    >
      {title || extra ? (
        <div className="mb-3 flex min-h-8 items-center justify-between gap-4">
          {title ? (
            <div
              data-slot="descriptions-title"
              className="font-semibold tracking-tight"
            >
              {title}
            </div>
          ) : (
            <span />
          )}
          {extra ? (
            <div data-slot="descriptions-extra" className="shrink-0">
              {extra}
            </div>
          ) : null}
        </div>
      ) : null}

      <div data-slot="descriptions-table-wrap" className="overflow-x-auto">
        <table
          data-slot="descriptions-table"
          className={cn(
            "w-full table-fixed border-collapse text-left text-sm",
            bordered && "border"
          )}
          style={{ minWidth: columns > 1 ? `${columns * 13}rem` : undefined }}
        >
          <colgroup>
            {Array.from({ length: columns }, (_, index) => (
              <React.Fragment key={index}>
                <col
                  style={
                    horizontal ? { width: normalizedLabelWidth } : undefined
                  }
                />
                {horizontal ? <col /> : null}
              </React.Fragment>
            ))}
          </colgroup>
          <tbody>
            {rows.flatMap((row, rowIndex) => {
              if (horizontal) {
                return (
                  <tr key={rowIndex}>
                    {row.map(({ item, span }, itemIndex) => {
                      const {
                        label,
                        span: _span,
                        labelClassName,
                        contentClassName,
                        className: itemClassName,
                        children: value,
                        ...itemProps
                      } = item.props
                      return (
                        <React.Fragment key={item.key ?? itemIndex}>
                          <th
                            scope="row"
                            data-slot="descriptions-label"
                            className={cn(
                              "text-muted-foreground align-top font-medium",
                              bordered
                                ? cn(cellPadding, "bg-muted/45 border")
                                : cn(compactPadding, "pr-4"),
                              labelClassName
                            )}
                          >
                            {label}
                          </th>
                          <td
                            data-slot="descriptions-value"
                            colSpan={span * 2 - 1}
                            className={cn(
                              "text-foreground min-w-0 break-words align-top",
                              bordered
                                ? cn(cellPadding, "border")
                                : cn(compactPadding, "pr-6"),
                              itemClassName,
                              contentClassName
                            )}
                            {...itemProps}
                          >
                            {value}
                          </td>
                        </React.Fragment>
                      )
                    })}
                  </tr>
                )
              }

              return [
                <tr key={`${rowIndex}-labels`}>
                  {row.map(({ item, span }, itemIndex) => {
                    return (
                      <th
                        key={item.key ?? itemIndex}
                        scope="col"
                        colSpan={span}
                        data-slot="descriptions-label"
                        className={cn(
                          "text-muted-foreground align-top font-medium",
                          bordered
                            ? cn(cellPadding, "bg-muted/45 border")
                            : "pr-6 pb-1",
                          item.props.labelClassName
                        )}
                      >
                        {item.props.label}
                      </th>
                    )
                  })}
                </tr>,
                <tr key={`${rowIndex}-values`}>
                  {row.map(({ item, span }, itemIndex) => {
                    const {
                      span: _span,
                      label: _label,
                      labelClassName: _labelClassName,
                      contentClassName,
                      className: itemClassName,
                      children: value,
                      ...itemProps
                    } = item.props
                    return (
                      <td
                        key={item.key ?? itemIndex}
                        colSpan={span}
                        data-slot="descriptions-value"
                        className={cn(
                          "text-foreground min-w-0 break-words align-top",
                          bordered
                            ? cn(cellPadding, "border")
                            : cn("pr-6", size === "sm" ? "pb-3" : "pb-4"),
                          itemClassName,
                          contentClassName
                        )}
                        {...itemProps}
                      >
                        {value}
                      </td>
                    )
                  })}
                </tr>,
              ]
            })}
          </tbody>
        </table>
      </div>
    </div>
  )
}

export { Descriptions, DescriptionsItem }

属性 Props

Descriptions

Descriptions 是外层容器组件,用于统一设定全局排版方向、列数、边框风格与密度:

属性类型默认值说明
titleReact.ReactNode—列表顶部左侧的主标题内容。
extraReact.ReactNode—列表顶部右侧的操作区内容(如编辑、刷新或更多操作按钮)。
columns1 | 2 | 3 | 43一行内展示的键值对列数。
borderedbooleanfalse是否为表格与每个单元格绘制网格线边框,并为标签单元格填充浅色背景。
layout"horizontal" | "vertical""horizontal"标签与数值的相对排列方向。horizontal 为左右并排,vertical 为上下堆叠。
size"sm" | "default""default"单元格内边距的视觉密度。
labelWidthReact.CSSProperties['width']"7rem"水平布局(horizontal)下所有标签单元格的固定宽度,支持像素或 rem 单位。
classNamestring—应用于最外层容器的额外 CSS 类名。

DescriptionsItem

用于声明单条“标签—值”数据项:

属性类型默认值说明
labelReact.ReactNode—数据项的标题/标签文本,具有明确的语义提示作用。
span1 | 2 | 3 | 41该数据项在当前行中所占据的列宽跨度(超过外层 columns 时按 columns 计算)。一行未排满时,该行最后一项会自动延展占满剩余列,边框网格保持完整。
labelClassNamestring—应用于当前项标签单元格(th)的额外 CSS 类名。
contentClassNamestring—应用于当前项内容单元格(td)的额外 CSS 类名。
childrenReact.ReactNode—当前项的具体内容或值。

事件 Events

Descriptions 本身作为只读结构化展示容器不直接发射业务事件,所有交互事件由嵌套在其中的子控件(如 extra 中的操作按钮、数据单元格内的复制按钮或链接)自行处理:

属性类型默认值说明
onClick(event: React.MouseEvent<HTMLDivElement>) => void—在容器上点击时触发的原生 DOM 事件。

使用场景与设计规范

Descriptions 专门用于呈现单个特定对象、记录或档案的属性全貌(如用户详情卡片、服务器实例参数、订单履约概览、合约条款)。

  • 何时使用 Descriptions vs Table:
    • Descriptions:专注于单个对象的多维度属性(每个字段含义不同,如“名称”、“创建时间”、“状态”、“配置详情”)。
    • Table:专注于多个同构对象的列表横向比对(每行结构相同,如“用户列表”、“日志记录”)。
  • 标签文案简明性:标签文案应简练明确(如“创建时间”而非“该条记录创建于系统的时间”);对于补充性业务说明,请放在对应的值内容中或通过 Tooltip 辅助说明。
  • 跨列合理性(Span):对于长文本、多标签列表或进度条等较宽内容,设置 span={2} 或 span={3} 占满整行,避免内容被挤压产生过长折行。
  • 边框风格选择:
    • 无边框(默认):视觉更轻量透气,适合嵌入卡片内部或个人主页等轻量场景。
    • 带边框(bordered):网格线清晰,标签与内容色彩分明,适合企业级管理后台、配置审批与工单详情页。

场景示例

带边框多列详情(实例档案)

在服务器或设备档案详情中,使用 bordered 模式搭配 span 跨列合并,结合 Badge 与复制按钮丰富信息呈现。最后一行只有一项时会自动延展占满整行:

Loading…

上下排版布局(订阅概览)

当空间横向较为受限,或者希望像仪表盘看板一样优先阅读指标数值时,使用 layout="vertical" 与 size="sm":

Loading…

无障碍与交互 Accessibility

  • 表格语义与读屏支持:底层基于原生 <table> 构建,水平布局时标签自动赋予 <th scope="row">,垂直布局时标签赋予 <th scope="col">,屏幕阅读器可以清晰地向视障用户读出属性名与属性值之间的关联。
  • 自适应横向滚动:当在极窄视口(如手机屏幕)下浏览多列描述列表时,组件外层自动包裹 overflow-x-auto 容器并设定最小列宽计算,保障内容不重叠、文字不异常断裂。
  • 高对比度设计:标签单元格采用 text-muted-foreground,内容单元格采用 text-foreground,在深色与浅色模式下均满足 WCAG AA 级对比度要求。