Something went wrong on our end
Select Git revision
deadmansswitch.mjs
-
Volker Schukai authoredVolker Schukai authored
table-of-content.mjs 13.64 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 { instanceSymbol } from "../../constants.mjs";
import { ATTRIBUTE_ROLE } from "../../dom/constants.mjs";
import { CustomElement } from "../../dom/customelement.mjs";
import {
assembleMethodSymbol,
registerCustomElement,
} from "../../dom/customelement.mjs";
import { TableOfContentStyleSheet } from "./stylesheet/table-of-content.mjs";
import { fireCustomEvent } from "../../dom/events.mjs";
import { getWindow } from "../../dom/util.mjs";
import "../layout/popper.mjs";
export { TableOfContent };
/**
* @private
* @type {symbol}
*/
const tableOfContentElementSymbol = Symbol("tableOfContentElement");
/**
* @private
* @type {symbol}
*/
const navigationElementSymbol = Symbol("navigation");
/**
* @private
* @type {symbol}
*/
const navigationControlElementSymbol = Symbol("navigationControlElement");
/**
* @private
* @type {symbol}
*/
const navigationListElementSymbol = Symbol("navigationListElement");
/**
* @private
* @type {symbol}
*/
const windowEventHandlerSymbol = Symbol("windowsEventHandler");
/**
* @private
* @type {symbol}
*/
const scrollableParentSymbol = Symbol("scrollableParent");
/**
* @private
* @type {symbol}
*/
const scrollableEventHandlerSymbol = Symbol("scrollableEventHandler");
/**
* A TableOfContent
*
* @fragments /fragments/components/form/table-of-content/
*
* @example /examples/components/form/table-of-content-simple
*
* @since 3.65.0
* @copyright schukai GmbH
* @summary A beautiful TableOfContent that can make your life easier and also looks good.
* @fires new-top The new top position
*/
class TableOfContent extends CustomElement {
/**
* This method is called by the `instanceof` operator.
* @return {symbol}
*/
static get [instanceSymbol]() {
return Symbol.for(
"@schukai/monster/components/navigation/table-of-content@@instance",
);
}
/**
*
* @return {Components.Navigation.TableOfContent
*/
[assembleMethodSymbol]() {
super[assembleMethodSymbol]();
initControlReferences.call(this);
initEventHandler.call(this);
return this;
}
/**
* 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} labels Label definitions
* @property {Object} features Features
* @property {boolean} features.showScrollToTop=true Show scroll to top
* @property {boolean} features.showScrollToBottom=true Show scroll to bottom
* @property {number} offset=100 Navigation offset from top
* @property {string} position="right" Navigation position (right, left)
* @property {Object} classes CSS classes
* @property {boolean} disabled=false Disabled state
*/
get defaults() {
return Object.assign({}, super.defaults, {
templates: {
main: getTemplate(),
},
labels: {
scrollToTop: "⇧",
scrollToBottom: "⇩",
},
classes: {},
disabled: false,
features: {
showScrollToTop: true,
showScrollToBottom: true,
},
offset: 50,
position: "right",
});
}
/**
* @return {void}
*/
connectedCallback() {
super.connectedCallback();
initNavigation.call(this);
const position = this.getOption("position");
if (position === "left") {
this[navigationElementSymbol].classList.remove("right");
this[navigationElementSymbol].classList.add("left");
} else {
this[navigationElementSymbol].classList.remove("left");
this[navigationElementSymbol].classList.add("right");
}
setTimeout(() => {
this[scrollableParentSymbol] = findScrollableParent(this);
if (this[scrollableParentSymbol] === getWindow()) {
if (
["absolute", "relative", "fixed", "sticky"].indexOf(
this[scrollableParentSymbol]?.style?.position,
) === -1
) {
this.style.position = "relative";
}
this[scrollableParentSymbol].addEventListener(
"scroll",
this[windowEventHandlerSymbol],
);
calcAndSetNavigationTopWindowContext.call(this);
} else {
if (
["absolute", "relative", "fixed", "sticky"].indexOf(
this[scrollableParentSymbol]?.style?.position,
) === -1
) {
this[scrollableParentSymbol].style.position = "relative";
}
this[scrollableParentSymbol].addEventListener(
"scroll",
this[scrollableEventHandlerSymbol],
);
calcAndSetNavigationTopScrollableParentContext.call(this);
}
}, 0);
}
/**
* @return {void}
*/
disconnectedCallback() {
super.disconnectedCallback();
if (!this[scrollableParentSymbol]) {
return;
}
if (this[scrollableParentSymbol] === getWindow()) {
this[scrollableParentSymbol].removeEventListener(
"scroll",
this[windowEventHandlerSymbol],
);
} else {
this[scrollableParentSymbol].removeEventListener(
"scroll",
this[scrollableEventHandlerSymbol],
);
}
}
/**
* @return {string}
*/
static getTag() {
return "monster-table-of-content";
}
/**
* @return {CSSStyleSheet[]}
*/
static getCSSStyleSheet() {
return [TableOfContentStyleSheet];
}
}
/**
* @private
* @param element
* @returns {number|number|*|number}
*/
function getScrollHeight(element) {
if (element instanceof ShadowRoot) {
return element.host.scrollHeight;
}
if (element === getWindow()) {
return element.document.documentElement.scrollHeight;
}
return element.scrollHeight;
}
/**
* @private
* @return {void}
* @fires new-top - The new top position
*/
function calcAndSetNavigationTopWindowContext() {
const rect = this.getBoundingClientRect();
const thisTop = rect.top;
const thisBottom = rect.bottom;
let top = 0;
if (thisTop < 0) {
top = +(-1 * thisTop);
}
const offset = this.getOption("offset");
if (offset > 0) {
top += offset;
}
if (thisBottom < 0) {
return;
}
fireCustomEvent(this, "new-top", { top: top });
this[navigationElementSymbol].style.top = top + "px";
}
/**
* @private
* @return {void}
* @fires new-top - The new top position
*/
function calcAndSetNavigationTopScrollableParentContext() {
if (!this[scrollableParentSymbol]) {
return;
}
const scrollTop = this[scrollableParentSymbol].scrollTop;
const thisTop = scrollTop;
let top = 0;
top += scrollTop;
const offset = this.getOption("offset");
if (offset > 0) {
top += offset;
}
fireCustomEvent(this, "new-top", { top: top });
this[navigationElementSymbol].style.top = top + "px";
}
/**
* @private
*/
function initNavigation() {
const headings = getHeadings.call(this);
for (const heading of headings) {
const div = document.createElement("div");
div.classList.add("heading-strip");
div.classList.add("level-" + heading.tagName.toLowerCase());
this[navigationControlElementSymbol].appendChild(div);
}
let startLevel = 7;
for (const heading of headings) {
if (parseInt(heading.tagName.substring(1)) < startLevel) {
startLevel = parseInt(heading.tagName.substring(1));
}
}
if (startLevel === 7) {
// no headings found
return;
}
this[navigationListElementSymbol].appendChild(
createListFromHeadings.call(this, headings, startLevel).sublist,
);
const footer = document.createElement("div");
footer.classList.add("footer");
if (this.getOption("features.showScrollToTop")) {
const scrollToTop = document.createElement("div");
scrollToTop.textContent = this.getOption("labels.scrollToTop");
scrollToTop.classList.add("scroll-to-top");
scrollToTop.addEventListener("click", () => {
if (!this[scrollableParentSymbol]) {
return;
}
this[scrollableParentSymbol].scrollTo(0, 0);
});
footer.appendChild(scrollToTop);
}
if (this.getOption("features.showScrollToBottom")) {
const scrollToBottom = document.createElement("div");
scrollToBottom.textContent = this.getOption("labels.scrollToBottom");
scrollToBottom.classList.add("scroll-to-bottom");
scrollToBottom.addEventListener("click", () => {
if (!this[scrollableParentSymbol]) {
return;
}
this[scrollableParentSymbol].scrollTo(
0,
getScrollHeight(this[scrollableParentSymbol]),
);
});
footer.appendChild(scrollToBottom);
}
if (footer.children.length > 0) {
this[navigationListElementSymbol].appendChild(footer);
}
}
/**
* Recursively creates a nested list (UL) from a list of heading elements.
* @param {HTMLElement[]} nodeList - The list of heading elements.
* @param {number} currentLevel - The current heading level we are processing.
* @return {{sublist: HTMLUListElement, lastIndex: number}} An object containing the sublist and the index of the last processed element.
*/
function createListFromHeadings(nodeList, currentLevel = 1) {
const self = this;
let ul = document.createElement("ul");
let i = 0;
while (i < nodeList.length) {
const node = nodeList[i];
const level = parseInt(node.tagName.substring(1));
if (level === currentLevel) {
const li = document.createElement("li");
li.textContent = node.textContent;
li.addEventListener("click", (e) => {
e.stopPropagation();
getWindow().requestAnimationFrame(() => {
window.scrollTo(0, 0);
// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView
// mostly supported
node?.scrollIntoView({ behavior: "smooth" });
});
});
ul.appendChild(li);
i++;
} else if (level > currentLevel) {
if (ul.lastChild) {
const { sublist, lastIndex } = createListFromHeadings.call(
self,
nodeList.slice(i),
level,
);
ul.lastChild.appendChild(sublist);
i += lastIndex;
} else {
throw new Error(
"Heading structure error: higher level " +
level +
" found without a parent (level " +
currentLevel +
")",
);
}
} else {
break;
}
}
return { sublist: ul, lastIndex: i };
}
/**
* @private
* @return {*[]}
*/
function getHeadings() {
const allHeadings = [];
const slots = this.shadowRoot.querySelectorAll("slot");
slots.forEach((slot) => {
const slottedElements = slot.assignedElements();
slottedElements.forEach((element) => {
if (element instanceof HTMLHeadingElement) {
allHeadings.push(element);
return;
}
const headings = element.querySelectorAll("h1, h2, h3, h4, h5, h6");
let nodeList = Array.from(headings);
nodeList = nodeList.filter((node) => {
return !node.hasAttribute("data-monster-table-of-content-omit");
});
allHeadings.push(...nodeList);
});
});
return allHeadings;
}
/**
* @private
* @return {initEventHandler}
*/
function initEventHandler() {
const self = this;
let ticking = false;
this[windowEventHandlerSymbol] = function () {
if (!ticking) {
getWindow().requestAnimationFrame(() => {
calcAndSetNavigationTopWindowContext.call(self);
ticking = false;
});
ticking = true;
}
};
this[scrollableEventHandlerSymbol] = function () {
if (!ticking) {
getWindow().requestAnimationFrame(() => {
calcAndSetNavigationTopScrollableParentContext.call(self);
ticking = false;
});
ticking = true;
}
};
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
getWindow().requestAnimationFrame(() => {
if (!this[scrollableParentSymbol]) {
return;
}
if (self[scrollableParentSymbol] === getWindow()) {
calcAndSetNavigationTopWindowContext.call(self);
} else {
calcAndSetNavigationTopScrollableParentContext.call(self);
}
ticking = false;
});
ticking = true;
}
});
},
{
root: null,
rootMargin: "0px",
threshold: 0.1,
},
);
observer.observe(this);
return this;
}
/**
*
* @param {HTMLElement} element
* @return {HTMLElement|Window}
*/
function findScrollableParent(element) {
let parent = element.parentElement;
while (parent) {
const overflowY = getWindow().getComputedStyle(parent).overflowY;
if (overflowY === "scroll" || overflowY === "auto") {
return parent;
}
parent = parent.parentElement;
}
return getWindow();
}
/**
* @private
* @return {void}
*/
function initControlReferences() {
this[tableOfContentElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}="control"]`,
);
this[navigationElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}="navigation"]`,
);
this[navigationControlElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}="navigation-control"]`,
);
this[navigationListElementSymbol] = this.shadowRoot.querySelector(
`[${ATTRIBUTE_ROLE}="navigation-list"]`,
);
}
/**
* @private
* @return {string}
*/
function getTemplate() {
// language=HTML
return `
<div data-monster-role="control" part="control">
<div class="navigation" data-monster-role="navigation">
<monster-popper data-monster-option-mode="enter">
<div slot="button" data-monster-role="navigation-control">
</div>
<div data-monster-role="navigation-list">
</div>
</monster-popper>
</div>
<slot></slot>
</div>`;
}
registerCustomElement(TableOfContent);