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