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>
)}
/>
)
}
使用
DataList 提供媒体、标题、描述、元信息和操作区的条目结构。同一份内容可以切换为横向列表或纵向卡片;分页、加载占位和空态由组件协调。它保留列表语义,不附加整行点击或选择行为。需要表头和列对齐时使用 DataTable。
传入 items、唯一且稳定的 itemKey 和标题字段 itemTitle,即可渲染基本列表。标题和描述也接受函数。
<DataList items={items} itemKey="id" itemTitle="name" itemDescription="description" />
按需提供 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 添加操作。
'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>
)}
/>
)
}
本地分页
开启 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
- 命运石之门 0STEINS;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>
)}
/>
)
}
筛选与排序
筛选和排序由调用方计算,再将结果传给 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>
)}
/>
)
}
远程分页
同时开启 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
}
/>
)}
/>
)
}
加载与空态
首次加载默认使用与条目结构对应的 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>
)
}
自定义分页
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)
}
/>
)}
/>
虚拟滚动
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>
)}
/>
)
}
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 |
viewport | HTMLElement | undefined | 设置高度或虚拟化时的滚动容器 |
scrollToIndex(index, options?) | align?: 'start' | 'center' | 'end' | 'auto'; behavior?: ScrollBehavior | 滚动到当前页内的条目,index 使用全局下标 |
scrollToIndex 在存在内部滚动容器时只滚动该容器;没有内部容器时,这个显式调用才会滚动外层祖先,让目标条目进入视口。
直接修改受控的 page 或 pageSize 不会再次调用 onPaginationChange;远程请求应根据这两个状态发起。所有数据类型均从包根导出,包括 DataListProps<T>、DataListItemSlot<T>、DataListState<T>、DataListVirtualOptions 和 DataListExpose。