feat(design-system): add reusable Pagination primitive
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:
sdarbinyan
2026-07-15 03:45:40 +04:00
parent bc46a755b1
commit bc03cc3d27
3 changed files with 143 additions and 0 deletions

View 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)"
>
&laquo;
</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)"
>
&raquo;
</app-button>
</nav>

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

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