feat(client): add Pagination component and integrate with Table and pages
- add Pagination component with prev/next, ellipsis page range, and optional page-size selector - add Pagination and Table pagination stories (WithPagination, WithPaginationAndSizeSelector) - extend Table with optional pageSize and pageSizeOptions props for built-in client-side pagination - add Timestamp column to Table story fixtures - enable pagination on all four pages with pageSize=10 and size selector - add pagination i18n keys to en.json
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-top: var(--border-width) solid var(--color-border);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-muted);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.pageBtn,
|
||||
.arrow {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0 var(--space-1);
|
||||
border: var(--border-width) solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition), border-color var(--transition), color var(--transition);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.pageBtn:hover:not(:disabled),
|
||||
.arrow:hover:not(:disabled) {
|
||||
background: var(--color-bg);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.pageBtn.active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-fg);
|
||||
border-color: var(--color-primary);
|
||||
font-weight: 700;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.arrow:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ellipsis {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 28px;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sizeSelect {
|
||||
height: 28px;
|
||||
padding: 0 var(--space-2);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-family);
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.sizeSelect:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import styles from './Pagination.module.css';
|
||||
|
||||
export interface PaginationProps {
|
||||
/** 1-indexed current page */
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onPageSizeChange?: (size: number) => void;
|
||||
pageSizeOptions?: number[];
|
||||
}
|
||||
|
||||
function buildPages(current: number, last: number): (number | '…')[] {
|
||||
if (last <= 7) return Array.from({ length: last }, (_, i) => i + 1);
|
||||
|
||||
const pages: (number | '…')[] = [1];
|
||||
|
||||
if (current > 3) pages.push('…');
|
||||
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(last - 1, current + 1);
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
|
||||
if (current < last - 2) pages.push('…');
|
||||
|
||||
pages.push(last);
|
||||
return pages;
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
pageSizeOptions = [10, 25, 50],
|
||||
}: PaginationProps) {
|
||||
const { t } = useTranslation();
|
||||
const lastPage = Math.max(1, Math.ceil(total / pageSize));
|
||||
const from = Math.min(total, (page - 1) * pageSize + 1);
|
||||
const to = Math.min(total, page * pageSize);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<span className={styles.info}>{t('pagination.range', { from, to, total })}</span>
|
||||
|
||||
<div className={styles.pages}>
|
||||
<button
|
||||
className={styles.arrow}
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page <= 1}
|
||||
aria-label={t('pagination.prev')}
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
</button>
|
||||
|
||||
{buildPages(page, lastPage).map((p, i) =>
|
||||
p === '…' ? (
|
||||
<span key={`ellipsis-${i}`} className={styles.ellipsis}>
|
||||
…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
className={`${styles.pageBtn} ${p === page ? styles.active : ''}`}
|
||||
onClick={() => onPageChange(p)}
|
||||
aria-current={p === page ? 'page' : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
|
||||
<button
|
||||
className={styles.arrow}
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= lastPage}
|
||||
aria-label={t('pagination.next')}
|
||||
>
|
||||
<ChevronRight size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{onPageSizeChange && (
|
||||
<select
|
||||
className={styles.sizeSelect}
|
||||
value={pageSize}
|
||||
onChange={(e) => {
|
||||
onPageSizeChange(Number(e.target.value));
|
||||
onPageChange(1);
|
||||
}}
|
||||
aria-label={t('pagination.page_size')}
|
||||
>
|
||||
{pageSizeOptions.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t('pagination.per_page', { count: s })}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Pagination } from '../Pagination/Pagination';
|
||||
import styles from './Table.module.css';
|
||||
|
||||
export interface TableColumn<T> {
|
||||
@@ -16,6 +17,10 @@ export interface TableProps<T> {
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
/** Enable built-in client-side pagination with this default page size */
|
||||
pageSize?: number;
|
||||
/** Offer a page-size selector when pagination is enabled */
|
||||
pageSizeOptions?: number[];
|
||||
}
|
||||
|
||||
export function Table<T>({
|
||||
@@ -25,7 +30,19 @@ export function Table<T>({
|
||||
loading = false,
|
||||
emptyMessage = 'No data.',
|
||||
className,
|
||||
pageSize: defaultPageSize,
|
||||
pageSizeOptions,
|
||||
}: TableProps<T>) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(defaultPageSize ?? 0);
|
||||
|
||||
const paginated = defaultPageSize !== undefined && !loading;
|
||||
const visibleData = paginated ? data.slice((page - 1) * pageSize, page * pageSize) : data;
|
||||
|
||||
// Reset to page 1 when data changes and current page would be empty
|
||||
const lastPage = pageSize > 0 ? Math.ceil(data.length / pageSize) : 1;
|
||||
const safePage = Math.min(page, Math.max(1, lastPage));
|
||||
|
||||
return (
|
||||
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
||||
<table className={styles.table}>
|
||||
@@ -52,14 +69,14 @@ export function Table<T>({
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
) : data.length === 0 ? (
|
||||
) : visibleData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className={styles.empty}>
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row) => (
|
||||
visibleData.map((row) => (
|
||||
<tr key={rowKey(row)} className={styles.tr}>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
@@ -75,6 +92,23 @@ export function Table<T>({
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{paginated && data.length > 0 && (
|
||||
<Pagination
|
||||
page={safePage}
|
||||
pageSize={pageSize}
|
||||
total={data.length}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={
|
||||
pageSizeOptions
|
||||
? (size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
pageSizeOptions={pageSizeOptions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,5 +24,8 @@ export { ThemeSwitcher } from './ThemeSwitcher/ThemeSwitcher';
|
||||
export { Timestamp } from './Timestamp/Timestamp';
|
||||
export type { TimestampProps } from './Timestamp/Timestamp';
|
||||
|
||||
export { Pagination } from './Pagination/Pagination';
|
||||
export type { PaginationProps } from './Pagination/Pagination';
|
||||
|
||||
export { Table } from './Table/Table';
|
||||
export type { TableProps, TableColumn } from './Table/Table';
|
||||
|
||||
Reference in New Issue
Block a user