Something went wrong on our end
Select Git revision
-
Volker Schukai authoredVolker Schukai authored
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);