feat(design-system): add reusable Pagination primitive
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
Standalone, OnPush. Computes truncated page range with ellipsis, composes existing app-button (ghost/sm) for prev/next controls rather than duplicating button styling. aria-current on active page, nav aria-label. Colors/radius/ spacing via existing tenant CSS vars with fallbacks. Completes Sprint 6 Design System primitives: Button, Input, Card, Badge, Dialog, FormField, EmptyState, Skeleton, Table, Pagination.
This commit is contained in:
37
src/app/shared/ui/pagination/pagination.component.html
Normal file
37
src/app/shared/ui/pagination/pagination.component.html
Normal file
@@ -0,0 +1,37 @@
|
||||
<nav class="app-pagination" aria-label="Pagination">
|
||||
<app-button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
[disabled]="currentPage() === 1"
|
||||
[attr.aria-label]="previousLabel()"
|
||||
(click)="goTo(currentPage() - 1)"
|
||||
>
|
||||
«
|
||||
</app-button>
|
||||
|
||||
@for (page of pages(); track $index) {
|
||||
@if (isEllipsis(page)) {
|
||||
<span class="app-pagination__ellipsis" aria-hidden="true">{{ page }}</span>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="app-pagination__page"
|
||||
[class.app-pagination__page--active]="page === currentPage()"
|
||||
[attr.aria-current]="page === currentPage() ? 'page' : null"
|
||||
(click)="goTo(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
<app-button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
[disabled]="currentPage() === totalPages()"
|
||||
[attr.aria-label]="nextLabel()"
|
||||
(click)="goTo(currentPage() + 1)"
|
||||
>
|
||||
»
|
||||
</app-button>
|
||||
</nav>
|
||||
48
src/app/shared/ui/pagination/pagination.component.scss
Normal file
48
src/app/shared/ui/pagination/pagination.component.scss
Normal file
@@ -0,0 +1,48 @@
|
||||
.app-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs, 0.25rem);
|
||||
}
|
||||
|
||||
.app-pagination__page {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 var(--space-xs, 0.25rem);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
background: transparent;
|
||||
color: var(--text-primary, #1f322d);
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast, 120ms ease),
|
||||
border-color var(--transition-fast, 120ms ease);
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-secondary, #f4f4f5);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--primary-color, #2f6e5d);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-pagination__page--active {
|
||||
background: var(--primary-color, #2f6e5d);
|
||||
color: var(--bg-primary, #fff);
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-color, #2f6e5d);
|
||||
}
|
||||
}
|
||||
|
||||
.app-pagination__ellipsis {
|
||||
padding: 0 var(--space-xs, 0.25rem);
|
||||
color: var(--text-secondary, #6b7280);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-pagination__page {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
58
src/app/shared/ui/pagination/pagination.component.ts
Normal file
58
src/app/shared/ui/pagination/pagination.component.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input, output } from '@angular/core';
|
||||
import { ButtonComponent } from '../button/button.component';
|
||||
|
||||
const SIBLING_COUNT = 1;
|
||||
const ELLIPSIS = '…' as const;
|
||||
|
||||
@Component({
|
||||
selector: 'app-pagination',
|
||||
standalone: true,
|
||||
imports: [ButtonComponent],
|
||||
templateUrl: './pagination.component.html',
|
||||
styleUrl: './pagination.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PaginationComponent {
|
||||
readonly currentPage = input.required<number>();
|
||||
readonly totalPages = input.required<number>();
|
||||
readonly previousLabel = input('Previous');
|
||||
readonly nextLabel = input('Next');
|
||||
|
||||
readonly pageChange = output<number>();
|
||||
|
||||
protected readonly pages = computed<(number | typeof ELLIPSIS)[]>(() => {
|
||||
const total = this.totalPages();
|
||||
const current = this.currentPage();
|
||||
const result: (number | typeof ELLIPSIS)[] = [];
|
||||
|
||||
const rangeStart = Math.max(2, current - SIBLING_COUNT);
|
||||
const rangeEnd = Math.min(total - 1, current + SIBLING_COUNT);
|
||||
|
||||
result.push(1);
|
||||
if (rangeStart > 2) {
|
||||
result.push(ELLIPSIS);
|
||||
}
|
||||
for (let page = rangeStart; page <= rangeEnd; page++) {
|
||||
result.push(page);
|
||||
}
|
||||
if (rangeEnd < total - 1) {
|
||||
result.push(ELLIPSIS);
|
||||
}
|
||||
if (total > 1) {
|
||||
result.push(total);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
protected goTo(page: number): void {
|
||||
if (page === this.currentPage() || page < 1 || page > this.totalPages()) {
|
||||
return;
|
||||
}
|
||||
this.pageChange.emit(page);
|
||||
}
|
||||
|
||||
protected isEllipsis(value: number | typeof ELLIPSIS): value is typeof ELLIPSIS {
|
||||
return value === ELLIPSIS;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user