import { LitElement, html, css } from "lit"; import { property, state } from "lit/decorators.js"; import { when } from "lit/directives/when.js"; import { classMap } from "lit/directives/class-map.js"; import { ifDefined } from "lit/directives/if-defined.js"; import { msg, localized, str } from "@lit/localize"; import chevronLeft from "../assets/icons/chevron-left.svg"; import chevronRight from "../assets/icons/chevron-right.svg"; import { srOnly } from "../utils/css"; export type PageChangeEvent = CustomEvent<{ page: number; pages: number; }>; /** * Pagination * * Usage example: * ```ts * * * ``` * * @event page-change { page: number; pages: number; } */ @localized() export class Pagination extends LitElement { static styles = [ srOnly, css` :host { --sl-input-height-small: var(--sl-font-size-x-large); --sl-input-color: var(--sl-color-neutral-500); } ul { align-items: center; list-style: none; margin: 0; padding: 0; color: var(--sl-input-color); } ul.compact { display: flex; } ul:not(.compact) { display: grid; grid-gap: var(--sl-spacing-x-small); grid-auto-flow: column; grid-auto-columns: min-content; } button { all: unset; display: flex; align-items: center; cursor: pointer; } sl-input::part(input) { text-align: center; padding: 0 0.5ch; } .currentPage { display: flex; align-items: center; width: fit-content; white-space: nowrap; } .pageInput { position: relative; margin-right: 0.5ch; } /* Use width of text to determine input width */ .totalPages { padding: 0 1ch; height: var(--sl-input-height-small); min-width: 1ch; visibility: hidden; } .input { position: absolute; top: 0; left: 0; width: 100%; } .navButton { display: grid; grid-template-columns: repeat(2, min-content); grid-gap: var(--sl-spacing-x-small); margin: 0 var(--sl-spacing-x-small); align-items: center; font-weight: 500; transition: opacity 0.2s; min-height: 1.5rem; min-width: 1.5rem; } .navButton[disabled] { opacity: 0.4; } .navButton:not([disabled]):hover { opacity: 0.8; } .chevron { display: block; width: var(--sl-spacing-medium); height: var(--sl-spacing-medium); } .compact .navButton { display: flex; justify-content: center; } `, ]; @property({ type: Number }) page: number = 1; @property({ type: Number }) totalCount: number = 0; @property({ type: Number }) size: number = 10; @property({ type: Boolean }) compact = false; @state() private inputValue = ""; @state() private pages = 0; connectedCallback() { this.inputValue = `${this.page}`; super.connectedCallback(); } async willUpdate(changedProperties: Map) { if (changedProperties.has("totalCount") || changedProperties.has("size")) { this.calculatePages(); } if (changedProperties.get("page") && this.page) { this.inputValue = `${this.page}`; } } render() { if (this.pages < 2) { return; } return html`
`; } private renderInputPage = () => html` `; private renderInput() { return html`
${this.pages}
{ // Prevent typing non-numeric keys if (e.key.length === 1 && /\D/.test(e.key)) { e.preventDefault(); } }} @keyup=${(e: any) => { const { key } = e; if (key === "ArrowUp" || key === "ArrowRight") { this.inputValue = `${Math.min(+this.inputValue + 1, this.pages)}`; } else if (key === "ArrowDown" || key === "ArrowLeft") { this.inputValue = `${Math.max(+this.inputValue - 1, 1)}`; } else { this.inputValue = e.target.value; } }} @sl-change=${(e: any) => { const page = +e.target.value; let nextPage = page; if (page < 1) { nextPage = 1; } else if (page > this.pages) { nextPage = this.pages; } else { nextPage = page; } this.onPageChange(nextPage); }} @focus=${(e: any) => { // Select text on focus for easy typing e.target.select(); }} >
`; } private renderPages = () => { const pages = Array.from({ length: this.pages }).map((_, i) => i + 1); const middleVisible = 3; const middlePad = Math.floor(middleVisible / 2); const middleEnd = middleVisible * 2 - 1; const endsVisible = 2; if (this.pages > middleVisible + middleEnd) { const currentPageIdx = pages.indexOf(this.page); const firstPages = pages.slice(0, endsVisible); const lastPages = pages.slice(-1 * endsVisible); let middlePages = pages.slice(endsVisible, middleEnd); if (currentPageIdx > middleVisible) { middlePages = pages.slice( Math.min(currentPageIdx - middlePad, this.pages - middleEnd), Math.min(currentPageIdx + middlePad + 1, this.pages - endsVisible) ); } return html` ${firstPages.map(this.renderPageButton)} ${when( middlePages[0] > firstPages[firstPages.length - 1] + 1, () => html`...` )} ${middlePages.map(this.renderPageButton)} ${when( lastPages[0] > middlePages[middlePages.length - 1] + 1, () => html`...` )} ${lastPages.map(this.renderPageButton)} `; } return html`${pages.map(this.renderPageButton)}`; }; private renderPageButton = (page: number) => { const isCurrent = page === this.page; return html`
  • this.onPageChange(page)} aria-disabled=${isCurrent} >${page}
  • `; }; private onPrev() { this.onPageChange(this.page > 1 ? this.page - 1 : 1); } private onNext() { this.onPageChange(this.page < this.pages ? this.page + 1 : this.pages); } private onPageChange(page: number) { this.dispatchEvent( new CustomEvent("page-change", { detail: { page: page, pages: this.pages }, composed: true, }) ); } private calculatePages() { if (this.totalCount && this.size) { this.pages = Math.ceil(this.totalCount / this.size); } else { this.pages = 0; } } }