wui
组件

描述列表 Descriptions

以清晰的键值结构展示对象、记录与档案信息。

基础示例

Loading…
pnpm dlx wui@latest add @wui/descriptions
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>

function groupItems(children: React.ReactNode, columns: number) {
  const items = React.Children.toArray(children).filter(
    React.isValidElement
  ) as DescriptionItemElement[]
  const rows: DescriptionItemElement[][] = []
  let row: DescriptionItemElement[] = []
  let occupied = 0

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

  if (row.length) rows.push(row)
  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 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, itemIndex) => {
                      const {
                        label,
                        span = 1,
                        labelClassName,
                        contentClassName,
                        className: itemClassName,
                        children: value,
                        ...itemProps
                      } = item.props
                      const safeSpan = Math.min(span, columns)
                      return (
                        <React.Fragment key={item.key ?? itemIndex}>
                          <th
                            scope="row"
                            data-slot="descriptions-label"
                            className={cn(
                              "text-muted-foreground align-top font-medium",
                              cellPadding,
                              bordered && "bg-muted/45 border",
                              labelClassName
                            )}
                          >
                            {label}
                          </th>
                          <td
                            data-slot="descriptions-value"
                            colSpan={safeSpan * 2 - 1}
                            className={cn(
                              "text-foreground min-w-0 break-words align-top",
                              cellPadding,
                              bordered && "border",
                              itemClassName,
                              contentClassName
                            )}
                            {...itemProps}
                          >
                            {value}
                          </td>
                        </React.Fragment>
                      )
                    })}
                  </tr>
                )
              }

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

export { Descriptions, DescriptionsItem }

组件作用

Descriptions 用于只读地呈现项目、订单、账户或设备等单个对象的属性。它使用语义化描述列表,适合“字段—值”阅读;大量同构记录的横向比较应使用 Table。

组件属性

PropTypeDefaultDescription
titleReactNodeHeading rendered above the description list.
extraReactNodeAction rendered opposite the heading.
columnsenum3Number of item groups per row.
borderedbooleanfalseDraw a structured border around labels and values.
layout"horizontal" | "vertical"horizontalPlace labels beside or above their values.
size"sm" | "default"defaultVertical density.
labelWidthWidth<string | number>7remFixed width of every horizontal label cell.

每个 DescriptionsItem 接收必填的 label,并可通过 span 跨越多个描述列。

事件

描述列表本身不产生事件。通过 extra 放置编辑、复制等轻量操作;字段值中的链接或按钮仍保留各自的原生事件。

扩展使用

bordered 适合信息密度较高的管理页面,默认分隔线样式更轻;layout="vertical" 适合标签较长或窄容器。columnsspan 应配合使用,跨列总数不要超过当前行容量。