组件目录 · 场景文档

本页由 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 闭包得出的, 手写等价物不会出现在这里。

一条链接装走整页

/r/scene--list.json /llms/scenes/list.md

list.astro src/pages/showcase/demo/list.astro

---
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>

ComponentList.tsx src/pages/showcase/demo/ComponentList.tsx

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>
  );
}

页面级规则(页头、工具栏、表格、设置、详情、流程、状态)只写在 页面模式 一处,这里不复述——复述就会漂移。