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