UNPKG

@schukai/monster

Version:

Monster is a simple library for creating fast, robust and lightweight websites.

666 lines (575 loc) 15.8 kB
/** * Copyright © schukai GmbH and all contributing authors, {{copyRightYear}}. All rights reserved. * Node module: @schukai/monster * * This source code is licensed under the GNU Affero General Public License version 3 (AGPLv3). * The full text of the license can be found at: https://www.gnu.org/licenses/agpl-3.0.en.html * * For those who do not wish to adhere to the AGPLv3, a commercial license is available. * Acquiring a commercial license allows you to use this software without complying with the AGPLv3 terms. * For more information about purchasing a commercial license, please contact schukai GmbH. * * SPDX-License-Identifier: AGPL-3.0 */ import { assembleMethodSymbol, CustomElement, registerCustomElement, } from "../../dom/customelement.mjs"; import { findElementWithSelectorUpwards, getWindow } from "../../dom/util.mjs"; import { DeadMansSwitch } from "../../util/deadmansswitch.mjs"; import { ThemeStyleSheet } from "../stylesheet/theme.mjs"; import { ATTRIBUTE_DATASOURCE_SELECTOR } from "./constants.mjs"; import { Datasource } from "./datasource.mjs"; import { Observer } from "../../types/observer.mjs"; import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs"; import { findTargetElementFromEvent } from "../../dom/events.mjs"; import { PaginationStyleSheet } from "./stylesheet/pagination.mjs"; import { DisplayStyleSheet } from "../stylesheet/display.mjs"; import { isString } from "../../types/is.mjs"; import { Pathfinder } from "../../data/pathfinder.mjs"; import { instanceSymbol } from "../../constants.mjs"; import { Formatter } from "../../text/formatter.mjs"; import "../form/select.mjs"; import { addAttributeToken } from "../../dom/attributes.mjs"; import { ATTRIBUTE_ERRORMESSAGE } from "../../dom/constants.mjs"; import "./datasource/dom.mjs"; import "./datasource/rest.mjs"; import { getLocaleOfDocument } from "../../dom/locale.mjs"; export { Pagination }; /** * @private * @type {symbol} */ const paginationElementSymbol = Symbol.for("paginationElement"); /** * @private * @type {symbol} */ const datasourceLinkedElementSymbol = Symbol("datasourceLinkedElement"); /** * @private * @type {symbol} */ const resizeObserverSymbol = Symbol("resizeObserver"); /** * @private * @type {symbol} */ const sizeDataSymbol = Symbol("sizeData"); /** * @private * @type {symbol} */ const debounceSizeSymbol = Symbol("debounceSize"); /** * A Pagination component * * @fragments /fragments/components/datatable/pagination * * @example /examples/components/datatable/pagination-simple Pagination * * @copyright schukai GmbH * @summary The Pagination component is used to show the current page and the total number of pages. */ class Pagination extends CustomElement { /** */ constructor() { super(); this[datasourceLinkedElementSymbol] = null; } /** * This method is called by the `instanceof` operator. * @return {symbol} */ static get [instanceSymbol]() { return Symbol.for("@schukai/monster/components/pagination"); } /** * To set the options via the HTML tag, the attribute `data-monster-options` must be used. * @see {@link https://monsterjs.org/en/doc/#configurate-a-monster-control} * * The individual configuration values can be found in the table. * * @property {Object} templates Template definitions * @property {string} templates.main Main template * @property {Object} datasource Datasource configuration * @property {string} datasource.selector Datasource selector * @property {Object} labels Label definitions * @property {string} labels.page Page label * @property {string} labels.description Description label * @property {string} labels.previous Previous label * @property {string} labels.next Next label * @property {string} labels.of Of label * @property {string} href Href * @property {number} currentPage Current page * @property {number} pages Pages * @property {number} objectsPerPage Objects per page * @property {Object} mapping Mapping * @property {string} mapping.pages Pages mapping * @property {string} mapping.objectsPerPage Objects per page mapping * @property {string} mapping.currentPage Current page mapping */ get defaults() { return Object.assign( {}, super.defaults, { templates: { main: getTemplate(), }, datasource: { selector: null, }, labels: getTranslations(), href: "page-${page}", pages: null, objectsPerPage: 20, currentPage: null, mapping: { pages: "sys.pagination.pages", objectsPerPage: "sys.pagination.objectsPerPage", currentPage: "sys.pagination.currentPage", }, /* @private */ pagination: { items: [], }, }, initOptionsFromArguments.call(this), ); } /** * * @return {string} */ static getTag() { return "monster-pagination"; } /** * @return {void} */ disconnectedCallback() { super.disconnectedCallback(); if (this?.[resizeObserverSymbol] instanceof ResizeObserver) { this[resizeObserverSymbol].disconnect(); } } /** * @return {void} */ connectedCallback() { super.connectedCallback(); const parentNode = this.parentNode; if (!parentNode) { return; } try { handleDataSourceChanges.call(this); } catch (e) { addAttributeToken(this, ATTRIBUTE_ERRORMESSAGE, e?.message || `${e}`); } requestAnimationFrame(() => { const parentParentNode = parentNode?.parentNode || parentNode; const parentWidth = parentParentNode.offsetWidth; const ownWidth = this.offsetWidth; this[sizeDataSymbol] = { last: { parentWidth: 0, }, showNumbers: ownWidth < parentWidth, }; this[resizeObserverSymbol] = new ResizeObserver(() => { if (this[debounceSizeSymbol] instanceof DeadMansSwitch) { try { this[debounceSizeSymbol].touch(); return; } catch (e) { delete this[debounceSizeSymbol]; } } this[debounceSizeSymbol] = new DeadMansSwitch(250, () => { queueMicrotask(() => { const parentWidth = parentParentNode.offsetWidth; const ownWidth = this.clientWidth; if (this[sizeDataSymbol]?.last?.parentWidth === parentWidth) { return; } this[sizeDataSymbol].last = { parentWidth: parentWidth, }; this[sizeDataSymbol].showNumbers = ownWidth <= parentWidth; handleDataSourceChanges.call(this); }); }); }); this[resizeObserverSymbol].observe(this?.parentNode?.parentNode); }); } /** * @return {void} */ [assembleMethodSymbol]() { super[assembleMethodSymbol](); initControlReferences.call(this); initEventHandler.call(this); const selector = this.getOption("datasource.selector", ""); if (isString(selector)) { const element = findElementWithSelectorUpwards(this, selector); if (element === null) { throw new Error("the selector must match exactly one element"); } if (!(element instanceof Datasource)) { throw new TypeError("the element must be a datasource"); } this[datasourceLinkedElementSymbol] = element; element.datasource.attachObserver( new Observer(handleDataSourceChanges.bind(this)), ); element.attachObserver(new Observer(handleDataSourceChanges.bind(this))); handleDataSourceChanges.call(this); } } /** * @private * @return {CSSStyleSheet} */ static getControlCSSStyleSheet() { return PaginationStyleSheet; } /** * @return {CSSStyleSheet[]} */ static getCSSStyleSheet() { return [this.getControlCSSStyleSheet(), DisplayStyleSheet, ThemeStyleSheet]; } } function getTranslations() { const locale = getLocaleOfDocument(); switch (locale.language) { case "de": return { page: "${page}", description: "Seite ${page}", previous: "Vorherige", next: "Nächste", of: "von", }; case "fr": return { page: "${page}", description: "Page ${page}", previous: "Précédent", next: "Suivant", of: "de", }; case "sp": return { page: "${page}", description: "Página ${page}", previous: "Anterior", next: "Siguiente", of: "de", }; case "it": return { page: "${page}", description: "Pagina ${page}", previous: "Precedente", next: "Successivo", of: "di", }; case "pl": return { page: "${page}", description: "Strona ${page}", previous: "Poprzednia", next: "Następna", of: "z", }; case "no": return { page: "${page}", description: "Side ${page}", previous: "Forrige", next: "Neste", of: "av", }; case "dk": return { page: "${page}", description: "Side ${page}", previous: "Forrige", next: "Næste", of: "af", }; case "sw": return { page: "${page}", description: "Sida ${page}", previous: "Föregående", next: "Nästa", of: "av", }; default: case "en": return { page: "${page}", description: "Page ${page}", previous: "Previous", next: "Next", of: "of", }; } } /** * @private * @return {Select} * @throws {Error} no shadow-root is defined */ function initControlReferences() { if (!this.shadowRoot) { throw new Error("no shadow-root is defined"); } this[paginationElementSymbol] = this.shadowRoot.querySelector( "[data-monster-role=pagination]", ); } /** * @private */ function initEventHandler() { const self = this; self[paginationElementSymbol].addEventListener("click", function (event) { let element = null; const datasource = self[datasourceLinkedElementSymbol]; if (!datasource) { return; } element = findTargetElementFromEvent( event, ATTRIBUTE_ROLE, "pagination-item", ); if (!element) { element = findTargetElementFromEvent( event, ATTRIBUTE_ROLE, "pagination-next", ); if (!element) { element = findTargetElementFromEvent( event, ATTRIBUTE_ROLE, "pagination-prev", ); if (!element) { return; } } } if (!(element instanceof HTMLElement)) { return; } let page = null; if (!element.hasAttribute("data-page-no")) { return; } page = element.getAttribute("data-page-no"); if ( !page || page === "" || page === "…" || page === null || page === undefined || page === "undefined" || page === "null" ) { return; } if (typeof datasource.setParameters !== "function") { return; } event.preventDefault(); datasource.setParameters({ page }); if (typeof datasource.reload !== "function") { return; } datasource.reload(); }); } /** * This attribute can be used to pass a URL to this select. * * ``` * <monster-form data-monster-datasource="restapi:....."></monster-form> * ``` * * @private * @return {object} * @throws {TypeError} incorrect arguments passed for the datasource * @throws {Error} the datasource could not be initialized */ function initOptionsFromArguments() { const options = {}; const selector = this.getAttribute(ATTRIBUTE_DATASOURCE_SELECTOR); if (selector) { options.datasource = { selector: selector }; } return options; } /** * @private */ function handleDataSourceChanges() { let pagination; if (!this[datasourceLinkedElementSymbol]) { return; } const mapping = this.getOption("mapping"); const pf = new Pathfinder(this[datasourceLinkedElementSymbol].data); for (const key in mapping) { const path = mapping[key]; if (pf.exists(path)) { const value = pf.getVia(path); this.setOption(key, value); } const o = this[datasourceLinkedElementSymbol].getOption(path); if (o !== undefined && o !== null) { this.setOption(key, o); } } pagination = buildPagination.call( this, this.getOption("currentPage"), this.getOption("pages"), ); if (this?.[sizeDataSymbol]?.showNumbers !== true) { pagination.items = []; } queueMicrotask(() => { this.setOption("pagination", pagination); }); } /** * @private * @param current * @param max * @return {object} */ function buildPagination(current, max) { current = parseInt(current, 10); max = parseInt(max, 10); let prev = current === 1 ? null : current - 1; let next = current === max ? null : current + 1; const itemList = [1]; if (current > 4) itemList.push(-1); const r = 2; const r1 = current - r; const r2 = current + r; for (let i = r1 > 2 ? r1 : 2; i <= Math.min(max, r2); i++) itemList.push(i); if (r2 + 1 < max) itemList.push(-1); if (r2 < max) itemList.push(max); let prevClass = ""; if (prev === null) { prevClass = " disabled"; } let nextClass = ""; if (next === null) { nextClass = " disabled"; } const items = itemList.map((item) => { let p = `${item}`; if (item === -1) { item = null; p = "…"; } const c = `${current}`; const obj = { pageNo: item, // as integer page: p, // as string current: p === c, class: (p === c ? "current" : "").trim(), }; if (item === null) { obj.class += " disabled".trim(); } const formatter = new Formatter(obj); obj.description = formatter.format(this.getOption("labels.description")); obj.label = formatter.format(this.getOption("labels.page")); obj.href = item === null ? "#" : p === c ? "#" : p === "1" ? "#" : `#${formatter.format(this.getOption("href"))}`; return obj; }); const nextNo = next; next = `${next}`; const nextHref = next === "null" ? "#" : `#${new Formatter({ page: next }).format(this.getOption("href"))}`; const prevNo = prev; prev = `${prev}`; const prevHref = prev === "null" ? "#" : `#${new Formatter({ page: prev }).format(this.getOption("href"))}`; return { current, nextNo, next, nextClass, nextHref, prevNo, prev, prevClass, prevHref, items, }; } /** * @private * @return {string} */ function getTemplate() { // language=HTML return ` <template id="items"> <li><a data-monster-attributes="class path:items.class, href path:items.href, aria-label path:items.description, disabled path:items.disabled:?disabled:undefined, data-page-no path:items.pageNo, aria-current path:items.current" data-monster-role="pagination-item" data-monster-replace="path:items.label"></a></li> </template> <div data-monster-role="control"> <nav data-monster-role="pagination" role="navigation" aria-label="pagination"> <ul class="pagination-list" data-monster-insert="items path:pagination.items" data-monster-select-this="true"> <li part="pagination-prev" data-monster-role="pagination-prev"><a data-monster-role="pagination-prev" data-monster-attributes=" class path:pagination.prevClass | prefix: previous, data-page-no path:pagination.prevNo, href path:pagination.prevHref | prefix: #" data-monster-replace="path:labels.previous">Previous</a></li> <li part="pagination-next" data-monster-role="pagination-next"><a data-monster-role="pagination-next" data-monster-attributes="class path:pagination.nextClass | prefix: next, data-page-no path:pagination.nextNo, href path:pagination.nextHref | prefix: #" data-monster-replace="path:labels.next">Next</a></li> </ul> </nav> </div> `; } registerCustomElement(Pagination);