本页由 5 个真实组件拼成;提示词与源码都可直接复制带走。
复现「组件目录」整页场景(/showcase/demo/list):
- 页面级规则按 /design#pages 的对应小节执行,不在此复述
- 本页由这些真实组件拼成(手写等价物不算):
- 玻璃可搜索下拉(List-filtering Combobox):input role=combobox + aria-expanded / aria-controls / aria-autocomplete=list / aria-activedescendant|/showcase/component/combobox/glass-apple
- 玻璃空态(Empty State):三件套:正面措辞的标题 + 一句说明 + 单个主操作;零数据不是错误,不用 warning 色|/showcase/component/empty-state/glass-apple
- 玻璃分页(Pagination):nav aria-label + ul/li;当前页 aria-current=page;每个页码 aria-label=第 n 页|/showcase/component/pagination/glass-apple
- 分段控件(Segmented Control (iOS)):APG radiogroup:方向键遍历 + roving tabindex|/showcase/component/segmented/glass-apple
- 玻璃数据表(Data Table):table + caption(视觉隐藏);表头 scope=col,首列当行头 scope=row|/showcase/component/table/glass-apple
- 骨架:视口高度固定,页头不滚,正文在容器内滚;分区页的页头与正文共用同一条内容列
清单是扫 src/pages/showcase/demo/list.astro 的 import 闭包得出的,
手写等价物不会出现在这里。
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PageHeader from '../../../components/PageHeader.astro';
import PageShell from '../../../components/PageShell.astro';
import SceneDocLink from '../../../components/SceneDocLink.astro';
import DemoChrome from '../../../components/DemoChrome.astro';
import ComponentList from './ComponentList.tsx';
---
<BaseLayout title="UI Echo · 列表场景">
<DemoChrome />
<PageShell demo col>
<PageHeader
slot="header"
title="组件列表"
description="查找组件,查看复现进度和最近更新。过滤、搜索、点击直达工作台。"
>
<Fragment slot="actions">
<a
href="/showcase/matrix"
class="press-feedback px-3 py-1.5 rounded-full text-xs border border-[var(--glass-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors"
>
矩阵视图
</a>
<SceneDocLink id="list" />
</Fragment>
</PageHeader>
<ComponentList client:visible />
</PageShell>
</BaseLayout>
import { useMemo, useState } from 'react';
import { GlassAppleCombobox, type ComboboxOption } from '~/components/combobox/glass-apple/Combobox';
import { GlassAppleEmptyState } from '~/components/empty-state/glass-apple/EmptyState';
import { GlassApplePagination } from '~/components/pagination/glass-apple/Pagination';
import { GlassAppleSegmented } from '~/components/segmented/glass-apple/Segmented';
import { GlassAppleTable, type TableColumn, type TableRow } from '~/components/table/glass-apple/Table';
import { TypeGlyph } from '~/components/TypeGlyph';
import { STYLE_LABEL, TYPE_LABEL } from '~/lib/component-meta';
import registry from '~/data/registry.json';
const ROWS = registry.components;
const STATUS_META: Record<string, { label: string; dot: string }> = {
done: { label: '已复现', dot: 'bg-[var(--color-success-fg)]' },
review: { label: '复现中', dot: 'bg-[var(--color-info-fg)]' },
draft: { label: '计划中', dot: 'bg-[var(--color-warning-fg)]' },
};
const COLUMNS: TableColumn[] = [
{ key: 'name', header: '组件' },
{ key: 'type', header: '类型' },
{ key: 'style', header: '材质' },
{ key: 'status', header: '状态' },
{ key: 'date', header: '更新', align: 'end' },
];
const VIEWS = [
{ value: 'cards', label: '卡片' },
{ value: 'table', label: '表格' },
];
const PAGE_SIZE = 12;
export default function ComponentList() {
const [filter, setFilter] = useState('all');
const [query, setQuery] = useState('');
const [page, setPage] = useState(1);
const [view, setView] = useState('cards');
// 真实计数:按数据算,不编数字
const chips = useMemo(() => {
const types = [...new Set(ROWS.map((r) => r.type))];
return [
{ id: 'all', label: '全部', count: ROWS.length },
...types.map((t) => ({
id: t,
label: TYPE_LABEL[t] ?? t,
count: ROWS.filter((r) => r.type === t).length,
})),
];
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return ROWS.filter((r) => {
if (filter !== 'all' && r.type !== filter) return false;
if (!q) return true;
// 中文类型名也要能搜——建议列表里点出来的就是它
return `${r.name} ${r.type} ${r.style} ${TYPE_LABEL[r.type] ?? ''}`.toLowerCase().includes(q);
});
}, [filter, query]);
const suggestions: ComboboxOption[] = chips.map((c) => ({
value: c.id,
label: c.label,
hint: String(c.count),
}));
const doneCount = filtered.filter((r) => r.status === 'done').length;
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const current = Math.min(page, pageCount);
const start = (current - 1) * PAGE_SIZE;
const paged = filtered.slice(start, start + PAGE_SIZE);
// 换筛选/关键词都从第一页重新看,避免停在已经不存在的页码上
const changeFilter = (id: string) => { setFilter(id); setPage(1); };
const changeQuery = (value: string) => { setQuery(value); setPage(1); };
const resetAll = () => { setQuery(''); setFilter('all'); setPage(1); };
return (
<div className="rounded-2xl bg-[var(--color-surface)] border border-[var(--glass-border)] shadow-[var(--shadow-sm)] overflow-hidden">
{/* 工具栏:第一行是"怎么看"(视图 + 搜索),第二行是"看哪些"(类型芯片)。
两件事不同行,否则视图开关的「表格」会和类型芯片「表格」撞在一起。 */}
<div className="px-5 py-4 border-b border-[var(--glass-border)]">
<div className="flex items-center gap-3 flex-wrap">
<GlassAppleSegmented options={VIEWS} value={view} onChange={setView} size="sm" />
<div className="w-full sm:w-64 shrink-0 sm:ml-auto">
<GlassAppleCombobox
options={suggestions}
value={query}
onChange={changeQuery}
onSelect={(o) => changeQuery(o.value === 'all' ? '' : o.label)}
placeholder="搜索组件…"
noResults="没有这个类型"
/>
</div>
</div>
<div className="mt-3 flex items-center gap-2 flex-wrap">
{chips.map((c) => (
<button
key={c.id}
type="button"
onClick={() => changeFilter(c.id)}
aria-pressed={filter === c.id}
className={`
press-feedback px-3 py-1.5 rounded-full text-xs border whitespace-nowrap
transition-colors cursor-pointer
${filter === c.id
? 'bg-[var(--color-text)] text-[var(--color-bg)] border-transparent'
: 'bg-[var(--color-surface)] text-[var(--color-text-muted)] border-[var(--glass-border)] hover:text-[var(--color-text)]'}
`}
>
{c.label}
<span className={`ml-1 font-mono text-[length:var(--text-2xs)] ${filter === c.id ? 'opacity-70' : 'opacity-50'}`}>
{c.count}
</span>
</button>
))}
</div>
</div>
{/* 行:已复现 → 真链接直达工作台;计划中 → 不可点,诚实呈现 */}
{filtered.length === 0 ? (
<GlassAppleEmptyState
icon={<TypeGlyph type="empty-state" size={18} />}
title={query ? `没有匹配「${query}」的组件` : '这一类下还没有组件'}
description="这里只列已复现的组件。换个关键词,或把筛选清掉。"
action={
<button
type="button"
onClick={resetAll}
className="press-feedback px-3 py-1.5 rounded-full text-xs border border-[var(--glass-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] transition-colors cursor-pointer"
>
清除筛选
</button>
}
/>
) : view === 'table' ? (
<GlassAppleTable
framed={false}
caption="组件复现清单"
columns={COLUMNS}
rows={paged.map((row) => {
const meta = STATUS_META[row.status];
const browsable = row.status !== 'draft';
const name = browsable ? (
<a
href={`/workbench?c=${row.type}/${row.style}`}
className="text-[var(--color-text)] hover:text-[var(--color-accent)] underline-offset-4 hover:underline"
>
{row.name}
</a>
) : (
row.name
);
const cells: TableRow['cells'] = {
name,
type: TYPE_LABEL[row.type] ?? row.type,
style: STYLE_LABEL[row.style] ?? row.style,
status: (
<span className="inline-flex items-center gap-1.5">
<i className={`size-1.5 rounded-full ${meta.dot}`} aria-hidden="true"></i>
{meta.label}
</span>
),
date: row.date,
};
return { id: `${row.type}/${row.style}`, cells };
})}
/>
) : (
paged.map((row) => {
const meta = STATUS_META[row.status];
const browsable = row.status !== 'draft';
const inner = (
<>
<div className="
shrink-0 size-9 rounded-lg
bg-[var(--glass-recess)] border border-[var(--glass-border)]
flex items-center justify-center
text-[var(--color-text-muted)] transition-colors
group-hover:text-[var(--color-text)]
">
<TypeGlyph type={row.type} />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{row.name}</div>
<div className="text-xs text-[var(--color-text-subtle)] mt-0.5 truncate">
{TYPE_LABEL[row.type] ?? row.type} · {row.style}
</div>
</div>
<span className="shrink-0 flex items-center gap-1.5 text-xs text-[var(--color-text-muted)]">
<i className={`size-1.5 rounded-full ${meta.dot}`} aria-hidden="true"></i>
{meta.label}
</span>
<span
className="shrink-0 text-xs text-[var(--color-text-subtle)] hidden md:inline w-24 text-right whitespace-nowrap"
title="该组件最后一次提交的日期"
>
{row.date}
</span>
{browsable && (
<span className="
shrink-0 text-[var(--color-text-subtle)]
transition-[color,translate] duration-[var(--dur-fast)]
group-hover:text-[var(--color-text)] group-hover:translate-x-0.5
" aria-hidden="true">→</span>
)}
</>
);
const rowCls = `
group flex items-center gap-3 sm:gap-4 px-5 py-3.5
border-b border-[var(--glass-border)] last:border-b-0
transition-[background-color,transform] duration-[var(--dur-fast)] ease-[var(--ease-standard)]
active:scale-[0.995]
${browsable ? 'hover:bg-[var(--glass-recess)]' : 'opacity-60'}
`;
return browsable ? (
<a key={row.name} href={`/workbench?c=${row.type}/${row.style}`} className={rowCls}>
{inner}
</a>
) : (
<div key={row.name} className={rowCls}>
{inner}
</div>
);
})
)}
{/* 页脚:真实分页,条数跟着筛选结果走 */}
<div className="flex items-center justify-between gap-3 flex-wrap px-5 py-3 border-t border-[var(--glass-border)] text-xs text-[var(--color-text-subtle)]">
<span>
{filtered.length === 0
? '0 条'
: `${start + 1}–${Math.min(start + PAGE_SIZE, filtered.length)} 条,共 ${filtered.length} 条`}
<span className="hidden sm:inline"> · {doneCount} 个已复现</span>
</span>
{pageCount > 1 && (
<GlassApplePagination page={current} pageCount={pageCount} onChange={setPage} />
)}
</div>
</div>
);
}
页面级规则(页头、工具栏、表格、设置、详情、流程、状态)只写在 页面模式 一处,这里不复述——复述就会漂移。