import { LitElement, html, css } from 'lit';
import { property } from 'lit/decorators.js';
export type PaginationOffset = 1 | 2;
export type PaginationJustify = 'start' | 'center' | 'end' | '';
export type PaginationGap = '...';
export type PageArrayItem = number | PaginationGap;
/**
* Event detail for page-change event
*/
export interface PageChangeEventDetail {
page: number;
pages: PageArrayItem[];
}
/**
* Custom event dispatched when the page changes
*/
export type PageChangeEvent = CustomEvent;
/**
* Navigation labels for first, previous, next, last buttons
*/
export interface NavigationLabels {
first: string;
previous: string;
next: string;
last: string;
}
/**
* Props interface for Pagination component including event handlers
*/
export interface PaginationProps {
/**
* Current active page (1-indexed)
*/
current?: number;
/**
* Total number of pages
*/
totalPages?: number;
/**
* Number of page buttons to show on each side of current page
*/
offset?: PaginationOffset;
/**
* Horizontal alignment of pagination controls
*/
justify?: PaginationJustify;
/**
* Alternative aria-label for the navigation
*/
ariaLabel?: string;
/**
* Show bordered style (outline instead of solid background for active page)
*/
bordered?: boolean;
/**
* Show first/last page navigation buttons
*/
firstLastNavigation?: boolean;
/**
* Custom labels for navigation buttons
*/
navigationLabels?: NavigationLabels;
/**
* Event callback fired when page changes
*/
onPageChange?: (event: PageChangeEvent) => void;
}
/**
* Pagination component for navigating through pages of content
*
* @fires {PageChangeEvent} page-change - Fired when the active page changes
* @csspart ag-pagination-container - The outer container element
* @csspart ag-pagination - The pagination list element
* @csspart ag-pagination-item - Individual pagination item wrapper
* @csspart ag-pagination-button - Individual pagination button
*
* @example
* ```html
*
* ```
*/
export class Pagination extends LitElement implements PaginationProps {
@property({ type: Number })
declare current: number;
@property({ type: Number, attribute: 'total-pages' })
declare totalPages: number;
@property({ type: Number })
declare offset: PaginationOffset;
@property({ type: String, reflect: true })
declare justify: PaginationJustify;
@property({ type: String, attribute: 'aria-label' })
declare ariaLabel: string;
@property({ type: Boolean, reflect: true })
declare bordered: boolean;
@property({ type: Boolean, reflect: true, attribute: 'first-last-navigation' })
declare firstLastNavigation: boolean;
@property({ type: Object, attribute: false })
declare navigationLabels: NavigationLabels;
@property({ attribute: false })
declare onPageChange?: (event: PageChangeEvent) => void;
constructor() {
super();
this.current = 1;
this.totalPages = 1;
this.offset = 2;
this.justify = '';
this.ariaLabel = 'pagination';
this.bordered = false;
this.firstLastNavigation = false;
this.navigationLabels = {
first: 'First',
previous: 'Previous',
next: 'Next',
last: 'Last',
};
}
get _pages(): PageArrayItem[] {
return this._generatePages();
}
private _generatePages(): PageArrayItem[] {
if (this.totalPages <= 1) {
return [1];
}
if (this.offset === 1) {
return this._generatePagingPaddedByOne(this.current, this.totalPages);
}
return this._generatePagingPaddedByTwo(this.current, this.totalPages);
}
private _getPaddedArray(
filtered: PageArrayItem[],
shouldIncludeLeftDots: boolean,
shouldIncludeRightDots: boolean,
totalCount: number
): PageArrayItem[] {
if (shouldIncludeLeftDots) {
filtered.unshift('...');
}
if (shouldIncludeRightDots) {
filtered.push('...');
}
if (totalCount <= 1) {
return [1];
}
return [1, ...filtered, totalCount];
}
private _generatePagingPaddedByOne(
current: number,
totalPageCount: number
): PageArrayItem[] {
const center = [current - 1, current, current + 1];
const filteredCenter: PageArrayItem[] = center.filter(
(p) => p > 1 && p < totalPageCount
);
const includeLeftDots = current > 3;
const includeRightDots = current < totalPageCount - 2;
return this._getPaddedArray(
filteredCenter,
includeLeftDots,
includeRightDots,
totalPageCount
);
}
private _generatePagingPaddedByTwo(
current: number,
totalPageCount: number
): PageArrayItem[] {
const center = [current - 2, current - 1, current, current + 1, current + 2];
const filteredCenter: PageArrayItem[] = center.filter(
(p) => p > 1 && p < totalPageCount
);
const includeThreeLeft = current === 5;
const includeThreeRight = current === totalPageCount - 4;
const includeLeftDots = current > 5;
const includeRightDots = current < totalPageCount - 4;
if (includeThreeLeft) {
filteredCenter.unshift(2);
}
if (includeThreeRight) {
filteredCenter.push(totalPageCount - 1);
}
return this._getPaddedArray(
filteredCenter,
includeLeftDots,
includeRightDots,
totalPageCount
);
}
private _handlePageChange(pageNumber: number) {
if (pageNumber < 1 || pageNumber > this.totalPages || pageNumber === this.current) {
return;
}
// Store pages before updating current (since _pages is a getter that depends on current)
const pages = this._pages;
// Update current page
this.current = pageNumber;
// Dual-dispatch: dispatchEvent + callback
const pageChangeEvent = new CustomEvent('page-change', {
detail: {
page: pageNumber,
pages: pages,
},
bubbles: true,
composed: true,
});
this.dispatchEvent(pageChangeEvent);
// Invoke callback if provided
if (this.onPageChange) {
this.onPageChange(pageChangeEvent);
}
// Focus the new current page button after re-render (only when user clicked)
this.updateComplete.then(() => {
const currentButton = this.shadowRoot?.querySelector(
`button[data-page="${this.current}"]`
) as HTMLButtonElement;
if (currentButton) {
currentButton.focus();
}
});
}
private _getJustifyClass(): string {
switch (this.justify) {
case 'start':
return 'pagination-start';
case 'center':
return 'pagination-center';
case 'end':
return 'pagination-end';
default:
return '';
}
}
static styles = css`
:host {
display: block;
}
.pagination-container {
display: flex;
font-size: var(--ag-font-size-sm);
}
:host([justify="start"]),
:host([justify="center"]),
:host([justify="end"]) {
width: 100%;
}
.pagination-center {
justify-content: center;
}
.pagination-start {
justify-content: flex-start;
}
.pagination-end {
justify-content: flex-end;
}
.pagination {
display: flex;
list-style: none;
margin: 0;
padding: 0;
}
.pagination-item {
margin-inline-start: 0.125rem;
margin-inline-end: 0.125rem;
}
/* The first previous next last buttons are slightly tighter */
.pagination-button-first,
.pagination-button-previous,
.pagination-button-next,
.pagination-button-last {
padding-inline-start: var(--ag-space-1);
padding-inline-end: var(--ag-space-1);
}
.pagination-button {
color: var(--ag-primary-text);
display: inline-block;
line-height: var(--ag-line-height-4);
padding-inline-start: var(--ag-space-2);
padding-inline-end: var(--ag-space-2);
border-radius: var(--ag-radius-md);
border: var(--ag-border-width-1) solid transparent;
background-color: transparent;
cursor: pointer;
font: inherit;
}
.pagination-button:focus-visible {
outline: var(--ag-focus-width) solid rgba(var(--ag-focus), 0.5);
outline-offset: var(--ag-focus-offset);
}
@media (prefers-reduced-motion), (update: slow) {
.pagination-button:focus-visible {
transition-duration: 0.001ms !important;
}
}
.pagination-item-disabled {
cursor: not-allowed;
}
.pagination-button:disabled,
.pagination-item-disabled .pagination-button {
color: var(--ag-text-disabled);
opacity: 0.8;
pointer-events: none;
}
.pagination-item-active .pagination-button {
background-color: var(--ag-background-tertiary);
color: var(--ag-primary-text);
}
:host([bordered]) .pagination-item-active .pagination-button {
background-color: transparent;
border: var(--ag-border-width-1) solid var(--ag-primary-text);
color: var(--ag-primary-text);
}
.pagination-item:hover .pagination-button {
text-decoration: none;
}
.pagination-item:not(.pagination-item-active):not(.pagination-item-disabled):hover
.pagination-button {
background-color: var(--ag-background-secondary);
}
.pagination-item-gap {
display: flex;
align-items: center;
transform: translateY(var(--ag-space-1));
}
`;
render() {
const justifyClass = this._getJustifyClass();
const pages = this._pages.length > 0 ? this._pages : [1];
const labels = this.navigationLabels || {
first: 'First',
previous: 'Previous',
next: 'Next',
last: 'Last',
};
return html`
`;
}
}