# 拟态分页（Pagination）

- 类型 / 材质：`pagination` / `neumorphic`（新拟态）
- 状态：done ｜ 最后提交：2026-11-01
- 灵感来源：Neumorphism（同色页码，悬停压成凹槽）
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/pagination/neumorphic` ｜ 条目：`/r/pagination--neumorphic.json`
- 必缺状态：焦点 / 禁用
- 整页场景：尚未接入

## 规格

- 同一份 props（page / pageCount / onChange）
- 无边框，悬停压一层内双阴影；当前页仍是墨色实心，不再压槽
- 页码窗口与禁用规则同玻璃分页

## 参数（CSS 变量）

`--color-bg` `--color-text` `--color-text-muted` `--color-text-subtle` `--neuro-bg` `--neuro-dark` `--neuro-light`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `page` | `number` | 必填 | 从 1 开始 |
| `pageCount` | `number` | 必填 | — |
| `onChange` | `(page: number) => void` | 必填 | — |
| `className` | `string` | 可选 | — |

## 提示词（可直接粘给智能体）

```
复现「拟态分页」— Pagination（分页 / Neumorphic）
灵感来源：Neumorphism（同色页码，悬停压成凹槽）
规格：
- 同一份 props（page / pageCount / onChange）
- 无边框，悬停压一层内双阴影；当前页仍是墨色实心，不再压槽
- 页码窗口与禁用规则同玻璃分页
参数（CSS 变量）：--color-bg --color-text --color-text-muted --color-text-subtle --neuro-bg --neuro-dark --neuro-light
必缺状态：焦点 / 禁用
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/pagination/neumorphic · 标准 interaction /design#interaction · 调研笔记 /showcase/component/pagination/neumorphic#notes
```

## 调研笔记

import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Icon } from '@/components/ui-echo/Icon';

interface Props {
  /** 从 1 开始 */
  page: number;
  pageCount: number;
  onChange: (page: number) => void;
  className?: string;
}

/** 页码窗口：总页数 ≤7 全显示，否则首尾 + 当前左右各 1，中间用省略号 */
function pageItems(page: number, pageCount: number): (number | 'gap')[] {
  if (pageCount <= 7) return Array.from({ length: pageCount }, (_, i) => i + 1);
  const items: (number | 'gap')[] = [1];
  const start = Math.max(2, page - 1);
  const end = Math.min(pageCount - 1, page + 1);
  if (start > 2) items.push('gap');
  for (let p = start; p <= end; p += 1) items.push(p);
  if (end < pageCount - 1) items.push('gap');
  items.push(pageCount);
  return items;
}

export function NeumorphicPagination({ page, pageCount, onChange, className = '' }: Props) {
  const go = (next: number) => {
    const target = Math.min(Math.max(1, next), pageCount);
    if (target !== page) onChange(target);
  };

  const base = `
    press-feedback grid place-items-center min-w-8 h-8 px-2 rounded-lg text-xs tabular-nums
    border transition-colors cursor-pointer
    disabled:opacity-40 disabled:cursor-not-allowed
  `;

  return (
    <nav aria-label="分页" className={className}>
      <ul className="flex items-center gap-1">
        <li>
          <button
            type="button"
            onClick={() => go(page - 1)}
            disabled={page <= 1}
            aria-label="上一页"
            className={`${base} border-transparent bg-[var(--neuro-bg)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:shadow-[inset_1px_1px_2px_var(--neuro-dark),inset_-1px_-1px_2px_var(--neuro-light)]`}
          >
            <Icon icon={ChevronLeft} size={13} />
          </button>
        </li>

        {pageItems(page, pageCount).map((item, i) =>
          item === 'gap' ? (
            <li key={`gap-${i}`} aria-hidden="true" className="px-1 text-xs text-[var(--color-text-subtle)]">
              …
            </li>
          ) : (
            <li key={item}>
              <button
                type="button"
                onClick={() => go(item)}
                aria-current={item === page ? 'page' : undefined}
                aria-label={`第 ${item} 页`}
                className={`
                  ${base}
                  ${item === page
                    ? 'border-transparent bg-[var(--color-text)] text-[var(--color-bg)]'
                    : 'border-transparent bg-[var(--neuro-bg)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:shadow-[inset_1px_1px_2px_var(--neuro-dark),inset_-1px_-1px_2px_var(--neuro-light)]'}
                `}
              >
                {item}
              </button>
            </li>
          ),
        )}

        <li>
          <button
            type="button"
            onClick={() => go(page + 1)}
            disabled={page >= pageCount}
            aria-label="下一页"
            className={`${base} border-transparent bg-[var(--neuro-bg)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] hover:shadow-[inset_1px_1px_2px_var(--neuro-dark),inset_-1px_-1px_2px_var(--neuro-light)]`}
          >
            <Icon icon={ChevronRight} size={13} />
          </button>
        </li>
      </ul>
    </nav>
  );
}
