DataList 数据列表

统一条目排版、列表与卡片布局、分页及加载状态的数据视图。

3 条作品

  • 夏日口袋
    Summer Pockets

    Key / 株式会社ビジュアルアーツ

  • CLANNAD

    Key / 株式会社ビジュアルアーツ

  • 亚托莉 -我挚爱的时光-
    ATRI -My Dear Moments-

    Frontwing Co., Ltd. / 枕 / ANIPLEX.EXE / iMel / Syawase Works / JAST USA

'use client'

import { DataList, Image, Stack, Text, Tooltip } from '@hina-ui/react'
import { dataListDemo } from '../../data-list'

const items = dataListDemo('zh-CN')

export default function Demo() {
  return (
    <DataList
      items={items.slice(0, 3)}
      itemKey="id"
      itemTitle="title"
      itemDescription="subtitle"
      mediaRatio={3 / 4}
      layoutToggle
      gridMin="10rem"
      label="作品"
      className="max-w-2xl"
      renderHeader={() => (
        <Text size="sm" tone="muted">
          3 条作品
        </Text>
      )}
      renderMedia={({ item }) => (
        <Image src={item.cover.src} alt="" fit="cover" className="size-full" />
      )}
      renderMeta={({ item }) => (
        <Stack gap="xs" className="min-w-0 w-full">
          <Tooltip content={item.developer}>
            <Text size="xs" tone="muted" truncate className="w-fit max-w-full">
              {item.developer}
            </Text>
          </Tooltip>
          <Text as="time" dateTime={item.released} size="xs" tone="muted">
            {item.released}
          </Text>
        </Stack>
      )}
    />
  )
}
tsx

使用

DataList 提供媒体、标题、描述、元信息和操作区的条目结构。同一份内容可以切换为横向列表或纵向卡片;分页、加载占位和空态由组件协调。它保留列表语义,不附加整行点击或选择行为。需要表头和列对齐时使用 DataTable。

传入 items、唯一且稳定的 itemKey 和标题字段 itemTitle,即可渲染基本列表。标题和描述也接受函数。

<DataList items={items} itemKey="id" itemTitle="name" itemDescription="description" />
tsx

按需提供 renderMedia、renderTitle、renderDescription、renderMeta 和 renderActions。这些渲染函数接收 { item, index, key, layout },保留完整条目类型。开启分页时,index 包含当前页偏移量。提供 children 函数可替换整个条目内容,同时保留列表容器、分页和状态管理。

示例使用 Hikarinagi 的公开作品数据快照(2026-09-18),包含名称、开发商、发行日期与封面;详情链接指向原始条目。分页、筛选和远程分页示例使用 1,000 条,虚拟滚动示例使用 5,000 条;排版示例展示其中少量条目。封面由 Image 展示,操作使用 Button,长文本提示使用 Tooltip。

示例

条目结构与布局

layoutToggle 显示内置布局切换器,也可以通过 layout / onLayoutChange 或 renderHeader 等状态渲染函数收到的 setLayout 控制。切换时保留仍在渲染范围内的条目节点。

列表中媒体位于文字前方,操作区在宽容器中靠后、窄容器中移至文字下方;卡片中媒体在上,操作区在下。标题和描述默认完整换行,元信息自动换行。需要截断时,可在对应的渲染函数中使用 Text 的截断能力。

mediaRatio 统一两种布局的媒体比例,省略时列表默认正方形,卡片默认 16:10。gridMin 设置最小卡片宽度,容器不足时使用单列;gridGap 设置间距。示例通过 Link 自定义标题,通过 Toggle 添加操作。

3 条作品

  • 終のステラ

    Key

  • planetarian ~ちいさなほしのゆめ~

    Key

  • STEINS;GATE

    ニトロプラス / MAGES. / 株式会社MAGES. / 株式会社角川書店 / スパイク・チュンソフト / 5pb.

'use client'

import { useState } from 'react'
import { Bookmark } from 'lucide-react'
import { DataList, Image, Link, Stack, Text, Toggle, Tooltip } from '@hina-ui/react'
import { dataListDemo } from '../../data-list'

const items = dataListDemo('zh-CN')

export default function Demo() {
  const [saved, setSaved] = useState<Record<number, boolean>>({})

  return (
    <DataList
      items={items.slice(3, 6)}
      itemKey="id"
      itemDescription="subtitle"
      defaultLayout="grid"
      layoutToggle
      gridMin="10rem"
      label="自定义条目"
      className="max-w-2xl"
      renderHeader={() => (
        <Text size="sm" tone="muted">
          3 条作品
        </Text>
      )}
      renderTitle={({ item }) => (
        <Link href={item.url} target="_blank" rel="noopener noreferrer" tone="neutral">
          {item.title}
        </Link>
      )}
      renderMedia={({ item }) => (
        <Image src={item.cover.src} alt="" fit="cover" className="size-full" />
      )}
      renderMeta={({ item }) => (
        <Stack gap="xs" className="min-w-0 w-full">
          <Tooltip content={item.developer}>
            <Text size="xs" tone="muted" truncate className="w-fit max-w-full">
              {item.developer}
            </Text>
          </Tooltip>
          <Text as="time" dateTime={item.released} size="xs" tone="muted">
            {item.released}
          </Text>
        </Stack>
      )}
      renderActions={({ item }) => (
        <Toggle
          value={saved[item.id] ?? false}
          onValueChange={value => setSaved(current => ({ ...current, [item.id]: value }))}
          size="sm"
          label={`标记 ${item.title}`}
          tooltip={false}
          renderIcon={({ pressed }) => (
            <Bookmark className={pressed ? 'fill-current' : undefined} />
          )}
        >
          {saved[item.id] ? '已标记' : '标记'}
        </Toggle>
      )}
    />
  )
}
tsx

本地分页

开启 pagination 后传入完整数组。组件截取当前页,并使用 Pagination 渲染分页。通过 page / onPageChange 和 pageSize / onPageSizeChange 控制页码与每页条数;数据减少导致页码越界时,自动回到最后一页。只有一页或没有数据时,默认分页自动隐藏;自定义 renderPagination 不受此限制。

renderFooter 与分页共用底部一行,空间不足时换行。翻页只重置列表自身的滚动位置,不滚动外层页面。

renderHeader、renderFooter、renderPagination 等状态渲染函数提供 setPage 和 setPageSize,处理加载状态和页码边界;修改每页条数会回到第一页。示例使用 Select 修改条数。

共 1000 条

  • 夏日口袋
    Summer Pockets

    2018-06-29

  • CLANNAD

    2004-04-28

  • 亚托莉 -我挚爱的时光-
    ATRI -My Dear Moments-

    2020-06-19

  • 星之终途
    終のステラ

    2022-09-30

  • 星之梦
    planetarian ~ちいさなほしのゆめ~

    2004-11-29

  • 命运石之门
    STEINS;GATE

    2009-10-15

  • Summer Pockets REFLECTION BLUE

    2020-06-26

  • Harmonia -ハルモニア-

    2016-09-23

  • 时廻者
    LOOPERS -ルーパーズ-

    2021-05-28

  • 命运石之门 0
    STEINS;GATE 0

    2015-12-10

'use client'

import { useState } from 'react'
import { DataList, Select, Text } from '@hina-ui/react'
import { dataListDemo } from '../../data-list'

const items = dataListDemo('zh-CN')
const sizes = [10, 20, 50].map(value => ({ value, label: `${value} 条 / 页` }))

export default function Demo() {
  const [page, setPage] = useState(1)
  const [pageSize, setPageSize] = useState(10)

  return (
    <DataList
      page={page}
      onPageChange={setPage}
      pageSize={pageSize}
      onPageSizeChange={setPageSize}
      items={items}
      itemKey="id"
      itemTitle="title"
      itemDescription="subtitle"
      pagination
      className="max-w-2xl"
      renderHeader={({ pageSize: size, setPageSize: resize }) => (
        <>
          <Text size="sm" tone="muted">
            共 {items.length} 条
          </Text>
          <Select
            value={size}
            options={sizes}
            size="sm"
            className="w-36 max-w-full"
            aria-label="每页条数"
            onValueChange={value => resize(Number(value))}
          />
        </>
      )}
      renderMeta={({ item }) => (
        <Text size="xs" tone="muted">
          {item.released}
        </Text>
      )}
    />
  )
}
tsx

筛选与排序

筛选和排序由调用方计算,再将结果传给 items。查询变化时重置页码。示例组合 SearchInput 与 Select。

  • 战国恋姬BRAVE二 ~战乱的九州、岛津篇~
    戦国†恋姫BRAVE弐 ~戦乱の九州、島津編~

    2026-08-28

  • ユリアリス

    2026-08-16

  • 両腕が折れたのでHなお世話をされる話

    2026-07-31

  • ネモフィリア-We pass each other-

    2026-07-31

  • ぼっちな魔王と俺の塔

    2026-07-31

  • お隣の天使様にいつの間にか駄目人間にされていた件 Memorial Vacation

    2026-07-23

  • 年上彼女2

    2026-06-26

  • as:9-nine- ARTEISIA

    2026-06-26

  • マガルミナ

    2026-06-26

  • Relirium - レリリウム - 遺跡と出逢いと冒険と

    2026-05-29

共 1000 条结果

'use client'

import { useMemo, useState } from 'react'
import { Button, DataList, Empty, SearchInput, Select, Text } from '@hina-ui/react'
import { dataListDemo } from '../../data-list'

const items = dataListDemo('zh-CN')
const options = [
  { value: 'release', label: '发行时间降序' },
  { value: 'title', label: '名称排序' },
]

export default function Demo() {
  const [query, setQuery] = useState('')
  const [order, setOrder] = useState<string | number | null | undefined>('release')
  const [page, setPage] = useState(1)
  const filtered = useMemo(
    () =>
      items
        .filter(item =>
          `${item.title} ${item.originalTitle} ${item.developer}`
            .toLocaleLowerCase()
            .includes(query.trim().toLocaleLowerCase()),
        )
        .toSorted((a, b) =>
          order === 'release'
            ? b.released.localeCompare(a.released)
            : a.title.localeCompare(b.title),
        ),
    [query, order],
  )

  return (
    <DataList
      page={page}
      onPageChange={setPage}
      items={filtered}
      itemKey="id"
      itemTitle="title"
      itemDescription="subtitle"
      pagination
      pageSize={10}
      label="筛选结果"
      className="max-w-2xl"
      renderHeader={() => (
        <>
          <SearchInput
            value={query}
            onValueChange={value => {
              setQuery(value)
              setPage(1)
            }}
            placeholder="搜索名称或开发商"
            aria-label="搜索"
            className="w-full @lg/hn-data-list:min-w-0 @lg/hn-data-list:flex-1"
          />
          <Select
            value={order}
            onValueChange={value => {
              setOrder(value)
              setPage(1)
            }}
            options={options}
            aria-label="排序方式"
            className="w-full @lg/hn-data-list:w-44"
          />
        </>
      )}
      renderMeta={({ item }) => (
        <Text size="xs" tone="muted">
          {item.released}
        </Text>
      )}
      renderEmpty={() => (
        <Empty
          title="没有匹配的作品"
          size="sm"
          icon={false}
          actions={
            <Button
              variant="outline"
              tone="neutral"
              size="sm"
              onClick={() => {
                setQuery('')
                setPage(1)
              }}
            >
              清空搜索
            </Button>
          }
        />
      )}
      renderFooter={({ total }) => (
        <Text size="xs" tone="muted">
          共 {total} 条结果
        </Text>
      )}
    />
  )
}
tsx

远程分页

同时开启 manual 和 pagination 时,items 是服务端返回的当前页,组件不会再次切片。提供 total 显示完整分页;总数未知时省略它,并传入 hasNextPage,分页将只显示上一页、下一页和当前页码。

示例实际请求按页保存的静态 JSON 快照,可运行于静态部署;它不实时查询作品库,也没有人为等待。使用 Switch 切换已知/未知总数。请求期间保留上一页,取消过期请求;失败时通过 Empty 显示重试入口。

加载中
'use client'

import { useCallback, useEffect, useRef, useState } from 'react'
import { Button, DataList, Empty, Switch, Text } from '@hina-ui/react'
import type { DataListDemoItem } from '../../data-list'

export default function Demo() {
  const [page, setPage] = useState(1)
  const [rows, setRows] = useState<DataListDemoItem[]>([])
  const [total, setTotal] = useState<number>()
  const [loading, setLoading] = useState(true)
  const [knownTotal, setKnownTotal] = useState(true)
  const [hasNextPage, setHasNextPage] = useState(false)
  const [failed, setFailed] = useState(false)
  const controller = useRef<AbortController>(undefined)

  const load = useCallback(async (target: number) => {
    controller.current?.abort()
    const request = new AbortController()
    controller.current = request
    setLoading(true)
    setFailed(false)
    try {
      const response = await fetch(`/demo/data-list/page-${target}.json`, {
        signal: request.signal,
      })
      if (!response.ok) throw new Error(String(response.status))
      const data = (await response.json()) as {
        items: Omit<DataListDemoItem, 'subtitle'>[]
        total: number
        hasNextPage: boolean
      }
      if (request.signal.aborted) return
      setRows(
        data.items.map(item => ({
          ...item,
          title: item.title,
          subtitle: item.title === item.originalTitle ? '' : item.originalTitle,
        })),
      )
      setTotal(data.total)
      setHasNextPage(data.hasNextPage)
    } catch {
      if (!request.signal.aborted) {
        setRows([])
        setFailed(true)
      }
    } finally {
      if (!request.signal.aborted) setLoading(false)
    }
  }, [])

  useEffect(() => {
    void load(page)
  }, [load, page])

  useEffect(() => () => controller.current?.abort(), [])

  return (
    <DataList
      page={page}
      onPageChange={setPage}
      items={rows}
      itemKey="id"
      itemTitle="title"
      itemDescription="subtitle"
      pagination
      manual
      pageSize={10}
      total={knownTotal ? total : undefined}
      hasNextPage={hasNextPage}
      loading={loading}
      minHeight={400}
      className="max-w-2xl"
      label="远程条目"
      renderHeader={() => (
        <>
          <Switch checked={knownTotal} onCheckedChange={setKnownTotal} disabled={loading}>
            已知总条数
          </Switch>
          <Button
            variant="ghost"
            tone="neutral"
            size="sm"
            disabled={loading}
            onClick={() => void load(page)}
          >
            刷新
          </Button>
        </>
      )}
      renderMeta={({ item }) => (
        <Text size="xs" tone="muted">
          {item.released}
        </Text>
      )}
      renderEmpty={() => (
        <Empty
          title={failed ? '加载失败' : '暂无数据'}
          size="sm"
          actions={
            failed ? (
              <Button size="sm" onClick={() => void load(page)}>
                重试
              </Button>
            ) : undefined
          }
        />
      )}
    />
  )
}
tsx

加载与空态

首次加载默认使用与条目结构对应的 Skeleton,placeholderCount 控制数量。默认只为已配置的内容区生成占位;虚拟模式默认渲染 3 个占位,普通分页默认与每页条数一致。自定义整个条目时,可用 renderPlaceholder 提供对应骨架,不需要伪造业务数据。

已有条目时,loading 会保留内容、禁止内容交互和翻页,并通过 LoadingOverlay 延迟显示加载提示。renderLoading 可替换整片首次加载内容以及刷新提示;renderEmpty 或 emptyText 自定义空态。

minHeight 为内容区预留最小高度,自动高度列表在清空数据加载时保留上一轮高度;height 则固定内容区高度,并在内容超出时使用 ScrollArea。

加载中
'use client'

import { useState } from 'react'
import { DataList, Image, SegmentedControl, Stack, Text } from '@hina-ui/react'
import { dataListDemo } from '../../data-list'

const items = dataListDemo('zh-CN')
const options = [
  { value: 'initial', label: '首次加载' },
  { value: 'ready', label: '内容' },
  { value: 'refresh', label: '刷新' },
  { value: 'empty', label: '空态' },
]

export default function Demo() {
  const [state, setState] = useState<string | number>('initial')
  const rows = state === 'initial' || state === 'empty' ? [] : items.slice(0, 2)

  return (
    <Stack className="w-full max-w-2xl">
      <SegmentedControl
        value={state}
        onValueChange={setState}
        options={options}
        size="sm"
        aria-label="列表状态"
      />
      <DataList
        items={rows}
        itemKey="id"
        itemTitle="title"
        itemDescription="subtitle"
        loading={state === 'initial' || state === 'refresh'}
        placeholderCount={2}
        mediaRatio={3 / 4}
        height={280}
        label="条目"
        renderMedia={({ item }) => (
          <Image src={item.cover.src} alt="" fit="cover" className="size-full" />
        )}
        renderMeta={({ item }) => (
          <Text size="xs" tone="muted">
            {item.released}
          </Text>
        )}
      />
    </Stack>
  )
}
tsx

自定义分页

renderPagination 接收与头部、页脚相同的状态和操作方法,可以接入 Pagination 的附属选项。

<DataList
  items={items}
  itemKey="id"
  itemTitle="name"
  pagination
  renderPagination={({ page, pageSize, total, loading, setPage, setPageSize }) => (
    <Pagination
      value={page}
      pageSize={pageSize}
      total={total ?? 0}
      pending={loading}
      pageSizeOptions={[10, 20, 50]}
      showInfo
      onChange={value =>
        value.pageSize === pageSize ? setPage(value.page) : setPageSize(value.pageSize)
      }
    />
  )}
/>
tsx

虚拟滚动

virtualize 支持列表和网格,与 VirtualList 复用窗口计算能力。网格按行虚拟化,以该行最高条目作为行高,列数随容器宽度调整。estimateSize 是列表行或网格整行的估算高度;overscan 是窗口前后额外保留的行数。高度会在渲染后动态测量。

height 指定滚动视口高度,虚拟模式默认 320px;百分比高度需要父容器具有明确高度。与分页组合时只虚拟化当前页。需要跨卸载保留的条目状态应按 ID 存在组件外部。

下方使用 5,000 条不同的真实条目,在列表与网格间切换,并显示当前可见范围。仅渲染当前窗口及缓冲区内的条目。

  • 夏日口袋
    Summer Pockets

    2018-06-29

  • CLANNAD

    2004-04-28

  • 亚托莉 -我挚爱的时光-
    ATRI -My Dear Moments-

    2020-06-19

  • 星之终途
    終のステラ

    2022-09-30

  • 星之梦
    planetarian ~ちいさなほしのゆめ~

    2004-11-29

可见范围:0–0 / 5000

'use client'

import { useState } from 'react'
import { DataList, Image, Text, type DataListLayout } from '@hina-ui/react'
import { dataListVirtualDemo } from '../../data-list-virtual'

const items = dataListVirtualDemo('zh-CN')

export default function Demo() {
  const [layout, setLayout] = useState<DataListLayout>('list')
  const [range, setRange] = useState({ startIndex: -1, endIndex: -1 })

  return (
    <DataList
      layout={layout}
      onLayoutChange={setLayout}
      items={items}
      itemKey="id"
      itemTitle="title"
      itemDescription="subtitle"
      layoutToggle
      mediaRatio={layout === 'grid' ? 16 / 10 : 3 / 4}
      gridMin="12rem"
      height={400}
      virtualize={{ estimateSize: layout === 'grid' ? 280 : 128, overscan: 1 }}
      bodyClass="border-line rounded-lg border"
      contentClass="p-4"
      label="虚拟条目"
      className="max-w-2xl"
      onRangeChange={setRange}
      renderMedia={({ item }) => (
        <Image src={item.cover.src} alt="" fit="cover" className="size-full" />
      )}
      renderMeta={({ item }) => (
        <Text size="xs" tone="muted">
          {item.released}
        </Text>
      )}
      renderFooter={() => (
        <Text size="xs" tone="muted">
          可见范围:{range.startIndex + 1}–{range.endIndex + 1} / {items.length}
        </Text>
      )}
    />
  )
}
tsx

SSR

列表、卡片、分页和首次加载骨架都支持服务端渲染。虚拟模式输出可读的初始窗口,挂载后校准视口和行高,不需要 client-only 包装。

虚拟网格可通过 initialColumns 指定服务端估算列数,默认 1。服务端和客户端初始值必须一致;实际列数由 CSS 和容器宽度决定。初始窗口之外的条目仍需在滚动后渲染。

API

属性

属性
类型
默认值
说明
items
readonly T[]
必填
完整数组;手动模式下为当前页
itemKey
键字段或 (item, index) => string | number
必填
唯一、稳定的键
itemTitle / itemDescription
文本字段或 (item, index) => string | number | null | undefined
—
标题/描述;对应渲染函数优先
mediaRatio
number
—
媒体宽高比
layoutToggle
boolean
false
显示布局切换器
gridMin
string
'14rem'
期望的网格最小列宽
gridGap
'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
'md'
网格间距
size
'sm' | 'md' | 'lg'
'md'
列表媒体大小、条目间距及卡片内边距
divided
boolean
true
列表分隔线
pagination
boolean
false
显示分页
manual
boolean
false
当前页数据不再切片
total
number
—
远程总条数;本地模式使用数组长度
hasNextPage
boolean
false
总数未知时是否有下一页
loading
boolean
false
加载状态,禁止内容交互和分页
placeholderCount
number
3 或每页条数
首次加载骨架数量
emptyText
string
语言包
默认空态文字
label
string
—
列表与滚动区的可访问名称
virtualize
boolean | DataListVirtualOptions
false
启用列表或网格虚拟化
height
number | string
自动;虚拟模式 320
内容区固定高度
minHeight
number | string
160
未设置固定高度时的内容区最小高度
className
string
—
根节点类名
bodyClass
string
—
内容区外框,涵盖加载与空态
contentClass
string
—
所有布局和渲染模式下均作用于条目列表容器
itemClass
string | ((item, index) => string | undefined)
—
单个条目类名

DataListVirtualOptions 包含 estimateSize(列表默认 112px、网格默认 280px)、overscan(默认 3 行)和 initialColumns(默认 1)。其他属性,包括 dir 和 style,透传到根节点。

受控状态

状态
类型
默认值
layout
'list' | 'grid'
'list'
page
number
1
pageSize
number
10

内容属性

属性
参数
说明
renderMedia / renderTitle / renderDescription / renderMeta / renderActions
DataListItemSlot<T>
条目对应区域
children
DataListItemSlot<T>
替换整个条目内容
renderPlaceholder
{ index, layout }
单个首次加载骨架
renderHeader / renderFooter
DataListState<T>
列表上方/底部与分页同一行
renderPagination
DataListState<T>
替换分页
renderEmpty
DataListState<T>
非加载状态下的空态
renderLoading
DataListState<T>
整片首次加载内容/刷新指示

DataListState<T> 包含当前页 items、page、pageSize、total、pageCount、layout、loading、refreshing、hasPreviousPage、hasNextPage、setPage(page)、setPageSize(size) 和 setLayout(layout)。未知总数时 total 和 pageCount 为 undefined。

回调与 Ref

名称参数/类型
说明
onPaginationChange{ page, pageSize }
分页操作或越界修正
onRangeChange{ startIndex, endIndex }
虚拟模式可见范围,包含分页偏移;无范围时为 -1
viewportHTMLElement | undefined
设置高度或虚拟化时的滚动容器
scrollToIndex(index, options?)align?: 'start' | 'center' | 'end' | 'auto'; behavior?: ScrollBehavior
滚动到当前页内的条目,index 使用全局下标

scrollToIndex 在存在内部滚动容器时只滚动该容器;没有内部容器时,这个显式调用才会滚动外层祖先,让目标条目进入视口。

直接修改受控的 page 或 pageSize 不会再次调用 onPaginationChange;远程请求应根据这两个状态发起。所有数据类型均从包根导出,包括 DataListProps<T>、DataListItemSlot<T>、DataListState<T>、DataListVirtualOptions 和 DataListExpose。