Skip to content
Snippets Groups Projects
Select Git revision
  • e9cbcfc578d58748bb45e1355a5a3d9e4a1d24d8
  • master default protected
  • 1.31
  • 4.24.3
  • 4.24.2
  • 4.24.1
  • 4.24.0
  • 4.23.6
  • 4.23.5
  • 4.23.4
  • 4.23.3
  • 4.23.2
  • 4.23.1
  • 4.23.0
  • 4.22.3
  • 4.22.2
  • 4.22.1
  • 4.22.0
  • 4.21.0
  • 4.20.1
  • 4.20.0
  • 4.19.0
  • 4.18.0
23 results

main.mjs

Blame
  • pagination.mjs 13.98 KiB
    /**
     * 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";
    
    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: {
    					page: "${page}",
    					description: "Page ${page}",
    					previous: "Previous",
    					next: "Next",
    					of: "of",
    				},
    
    				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((entries) => {
    				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];
    	}
    }
    
    /**
     * @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 === 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 = [];
    	}
    
    	getWindow().requestAnimationFrame(() => {
    		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("…");
    
    	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("…");
    	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) => {
    		const p = `${item}`;
    		const c = `${current}`;
    
    		const obj = {
    			pageNo: item, // as integer
    			page: p, // as string
    			current: p === c,
    			class: (p === c ? "current" : "").trim(),
    		};
    
    		if (p === "…") {
    			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 =
    			p === "…"
    				? "#"
    				: 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);