data-content-loader-target="#contentLoaderTarget".<div id="contentLoaderTarget">): The HTML code returned by the API is delivered to it.data-content-loader-trigger (e.g. <button data-content-loader-trigger data-content-loader-target="#contentLoaderTarget">Load content</button>).data-content-loader-action can be used to determine whether the loaded content should be appended ("append") or replace the existing content ("replace").data-content-loader-animated="true", the content change is animated (only works when replacing the content).data-content-loader-url that defines the URL of the API call.data-content-loader-url that defines the URL of the API call. The form data is appended to this URL by the script.<button type="submit"/>), an event handler (e.g. <input onchange="this.form.submit()"/>) or the data attribute data-content-loader-submit (<input data-content-loader-submit/>)<a data-content-loader-target="#contentLoaderTarget" data-content-loader-url="/items?page=2">.Process for successive loading:
data-content-loader-target are searched for (“triggers”).
<!-- Default -->
<div class="contentLoader"></div>
<!-- Demo Only Single -->
<section class="doc-section">
<h2 class="doc-section-title">Buttons</h2>
<div class="buttonGroup">
<button data-content-loader-trigger data-content-loader-action="replace" data-content-loader-target="#contentLoaderTarget-single" data-content-loader-url="/includes/contentLoader/item-1.html" class="button">Load content (0)</button>
<button data-content-loader-trigger data-content-loader-action="replace" data-content-loader-target="#contentLoaderTarget-single" data-content-loader-url="/includes/contentLoader/item-2.html" class="button">Load content (1)</button>
</div>
<div id="contentLoaderTarget-single" data-content-loader-animated="true" class="doc-box" style="margin-top: var(--sp-component);">
<div class="doc-notes">
[Container for content to be loaded]
</div>
</div>
</section>
<!-- Demo Only Form Tags -->
<section class="doc-section">
<h2 class="doc-section-title">Form with Tags</h2>
<form data-content-loader-trigger data-content-loader-action="replace" data-content-loader-target="#contentLoaderTarget-form-tags" data-content-loader-url="/includes/contentLoader/form-respond.html">
<div class="tagGroup">
<div class="tagGroup-item">
<label class="tag-container">
<input type="checkbox" name="filter[]" value="Church-key Viny" data-content-loader-submit />
<span class="tag" data-selectable="true">
Church-key Viny
</span>
</label>
</div>
<div class="tagGroup-item">
<label class="tag-container">
<input type="checkbox" name="filter[]" value="Flexitarian Retro" data-content-loader-submit />
<span class="tag" data-selectable="true">
Flexitarian Retro
</span>
</label>
</div>
<div class="tagGroup-item">
<label class="tag-container">
<input type="checkbox" name="filter[]" value="Hashtag Echo Park" data-content-loader-submit />
<span class="tag" data-selectable="true">
Hashtag Echo Park
</span>
</label>
</div>
</div>
</form>
<div id="contentLoaderTarget-form-tags" data-content-loader-animated="true" class="doc-box" style="margin-top: var(--sp-component);">
<div class="doc-notes">
[Container for content to be loaded]
</div>
</div>
</section>
<!-- Demo Only Form Combobox -->
<section class="doc-section">
<h2 class="doc-section-title">Form with Combobox</h2>
<form data-content-loader-trigger data-content-loader-action="replace" data-content-loader-target="#contentLoaderTarget-form-combobox" data-content-loader-url="/includes/contentLoader/form-respond.html">
<div style="margin-top: var(--sp-component);">
<div class="combobox" data-autosend data-multiselect>
<div class="combobox-field">
<span class="combobox-input-wrapper">
<input class="combobox-input" id="uid" role="combobox" aria-controls="menu-body" aria-autocomplete="list" placeholder="Please select">
</span>
</div>
<div class="combobox-dropdown" aria-hidden="true" tabindex="-1">
<ul class="combobox-list" data-noresults="Keine Treffer">
<li class="combobox-item" data-id="my-given-ID" data-name="my-given-name[]" data-value="1" data-label="Vinyl pug cardigan">
<label>Vinyl pug cardigan</label>
</li>
<li class="combobox-item" data-name="my-given-name[]" data-value="2" data-label="Flexitarian Retro">
<label>Flexitarian Retro</label>
</li>
<li class="combobox-item" data-name="my-given-name[]" data-value="3" data-label="Plaid 8-bit" aria-disabled="true">
<label>Plaid 8-bit</label>
</li>
<li class="combobox-item" data-name="my-given-name[]" data-value="4" data-label="Echo Echo Park">
<label>Echo Echo Park</label>
</li>
<li class="combobox-item" data-name="my-given-name[]" data-value="5" data-label="Echo Vinyl">
<label>Echo Vinyl</label>
</li>
<li class="combobox-item" data-name="my-given-name[]" data-value="6" data-label="Echo">
<label>Echo</label>
</li>
</ul>
</div>
</div>
</div>
</form>
<div id="contentLoaderTarget-form-combobox" data-content-loader-animated="true" class="doc-box" style="margin-top: var(--sp-component);">
<div class="doc-notes">
[Container for content to be loaded]
</div>
</div>
</section>
<!-- Demo Only Multiple -->
<section class="doc-section">
<h2 class="doc-section-title">Successive Loading</h2>
<div class="doc-notes">
<p><strong>List with the URLs of the content to be loaded</strong> (hidden in the application):</p>
<ul>
<li><a data-content-loader-target="#contentLoaderTarget-multiple" data-content-loader-url="/includes/contentLoader/item-1.html">/includes/contentLoader/item-1.html</a></li>
<li><a data-content-loader-target="#contentLoaderTarget-multiple" data-content-loader-url="/includes/contentLoader/item-2.html">/includes/contentLoader/item-2.html</a></li>
</ul>
</div>
<div id="contentLoaderTarget-multiple" data-content-loader-animated="true" class="doc-box" style="margin-top: var(--sp-component);">
<div class="doc-notes">
[Container for content to be loaded]
</div>
</div>
<p><button class="button" data-content-loader-trigger data-content-loader-action="append" data-content-loader-target="#contentLoaderTarget-multiple">Load more</button></p>
</section>
<!-- Default -->
<div class="contentLoader{{#modifier}} {{.}}{{/modifier}}"></div>
<!-- Demo Only Single -->
{{#demo}}
<section class="doc-section">
{{#title}}<h2 class="doc-section-title">{{{.}}}</h2>{{/title}}
{{#buttongroup}}
<div class="buttonGroup">
{{#urls}}
<button data-content-loader-trigger data-content-loader-action="replace" data-content-loader-target="#{{../../id}}" data-content-loader-url="{{.}}" class="button">Load content ({{@key}})</button>
{{/urls}}
</div>
{{/buttongroup}}
{{#form}}
<form data-content-loader-trigger data-content-loader-action="replace" data-content-loader-target="#{{../id}}" data-content-loader-url="{{action}}">
{{#taggroup}}
{{render '@taggroup' (contextData '@contentloader' this) merge=true}}
{{/taggroup}}
{{#combobox}}
<div style="margin-top: var(--sp-component);">
{{render '@combobox' (contextData '@contentloader' this) merge=true}}
</div>
{{/combobox}}
</form>
{{/form}}
{{#list}}
<div class="doc-notes">
<p><strong>List with the URLs of the content to be loaded</strong> (hidden in the application):</p>
<ul>
{{#urls}}
<li><a data-content-loader-target="#{{../../id}}" data-content-loader-url="{{.}}">{{{.}}}</a></li>
{{/urls}}
</ul>
</div>
{{/list}}
<div id="{{id}}" data-content-loader-animated="true" class="doc-box" style="margin-top: var(--sp-component);">
<div class="doc-notes">
[Container for content to be loaded]
</div>
</div>
{{#if list}}
<p><button class="button" data-content-loader-trigger data-content-loader-action="append" data-content-loader-target="#{{id}}">Load more</button></p>
{{/if}}
</section>
{{/demo}}
<!-- Demo Only Form Tags -->
{{#demo}}
<section class="doc-section">
{{#title}}<h2 class="doc-section-title">{{{.}}}</h2>{{/title}}
{{#buttongroup}}
<div class="buttonGroup">
{{#urls}}
<button data-content-loader-trigger data-content-loader-action="replace" data-content-loader-target="#{{../../id}}" data-content-loader-url="{{.}}" class="button">Load content ({{@key}})</button>
{{/urls}}
</div>
{{/buttongroup}}
{{#form}}
<form data-content-loader-trigger data-content-loader-action="replace" data-content-loader-target="#{{../id}}" data-content-loader-url="{{action}}">
{{#taggroup}}
{{render '@taggroup' (contextData '@contentloader' this) merge=true}}
{{/taggroup}}
{{#combobox}}
<div style="margin-top: var(--sp-component);">
{{render '@combobox' (contextData '@contentloader' this) merge=true}}
</div>
{{/combobox}}
</form>
{{/form}}
{{#list}}
<div class="doc-notes">
<p><strong>List with the URLs of the content to be loaded</strong> (hidden in the application):</p>
<ul>
{{#urls}}
<li><a data-content-loader-target="#{{../../id}}" data-content-loader-url="{{.}}">{{{.}}}</a></li>
{{/urls}}
</ul>
</div>
{{/list}}
<div id="{{id}}" data-content-loader-animated="true" class="doc-box" style="margin-top: var(--sp-component);">
<div class="doc-notes">
[Container for content to be loaded]
</div>
</div>
{{#if list}}
<p><button class="button" data-content-loader-trigger data-content-loader-action="append" data-content-loader-target="#{{id}}">Load more</button></p>
{{/if}}
</section>
{{/demo}}
<!-- Demo Only Form Combobox -->
{{#demo}}
<section class="doc-section">
{{#title}}<h2 class="doc-section-title">{{{.}}}</h2>{{/title}}
{{#buttongroup}}
<div class="buttonGroup">
{{#urls}}
<button data-content-loader-trigger data-content-loader-action="replace" data-content-loader-target="#{{../../id}}" data-content-loader-url="{{.}}" class="button">Load content ({{@key}})</button>
{{/urls}}
</div>
{{/buttongroup}}
{{#form}}
<form data-content-loader-trigger data-content-loader-action="replace" data-content-loader-target="#{{../id}}" data-content-loader-url="{{action}}">
{{#taggroup}}
{{render '@taggroup' (contextData '@contentloader' this) merge=true}}
{{/taggroup}}
{{#combobox}}
<div style="margin-top: var(--sp-component);">
{{render '@combobox' (contextData '@contentloader' this) merge=true}}
</div>
{{/combobox}}
</form>
{{/form}}
{{#list}}
<div class="doc-notes">
<p><strong>List with the URLs of the content to be loaded</strong> (hidden in the application):</p>
<ul>
{{#urls}}
<li><a data-content-loader-target="#{{../../id}}" data-content-loader-url="{{.}}">{{{.}}}</a></li>
{{/urls}}
</ul>
</div>
{{/list}}
<div id="{{id}}" data-content-loader-animated="true" class="doc-box" style="margin-top: var(--sp-component);">
<div class="doc-notes">
[Container for content to be loaded]
</div>
</div>
{{#if list}}
<p><button class="button" data-content-loader-trigger data-content-loader-action="append" data-content-loader-target="#{{id}}">Load more</button></p>
{{/if}}
</section>
{{/demo}}
<!-- Demo Only Multiple -->
{{#demo}}
<section class="doc-section">
{{#title}}<h2 class="doc-section-title">{{{.}}}</h2>{{/title}}
{{#buttongroup}}
<div class="buttonGroup">
{{#urls}}
<button data-content-loader-trigger data-content-loader-action="replace" data-content-loader-target="#{{../../id}}" data-content-loader-url="{{.}}" class="button">Load content ({{@key}})</button>
{{/urls}}
</div>
{{/buttongroup}}
{{#form}}
<form data-content-loader-trigger data-content-loader-action="replace" data-content-loader-target="#{{../id}}" data-content-loader-url="{{action}}">
{{#taggroup}}
{{render '@taggroup' (contextData '@contentloader' this) merge=true}}
{{/taggroup}}
{{#combobox}}
<div style="margin-top: var(--sp-component);">
{{render '@combobox' (contextData '@contentloader' this) merge=true}}
</div>
{{/combobox}}
</form>
{{/form}}
{{#list}}
<div class="doc-notes">
<p><strong>List with the URLs of the content to be loaded</strong> (hidden in the application):</p>
<ul>
{{#urls}}
<li><a data-content-loader-target="#{{../../id}}" data-content-loader-url="{{.}}">{{{.}}}</a></li>
{{/urls}}
</ul>
</div>
{{/list}}
<div id="{{id}}" data-content-loader-animated="true" class="doc-box" style="margin-top: var(--sp-component);">
<div class="doc-notes">
[Container for content to be loaded]
</div>
</div>
{{#if list}}
<p><button class="button" data-content-loader-trigger data-content-loader-action="append" data-content-loader-target="#{{id}}">Load more</button></p>
{{/if}}
</section>
{{/demo}}
/* Default: No context defined. */
/* Demo Only Single */
{
"demo": {
"id": "contentLoaderTarget-single",
"title": "Buttons",
"buttongroup": {
"urls": [
"/includes/contentLoader/item-1.html",
"/includes/contentLoader/item-2.html"
]
}
}
}
/* Demo Only Form Tags */
{
"demo": {
"id": "contentLoaderTarget-form-tags",
"title": "Form with Tags",
"form": {
"action": "/includes/contentLoader/form-respond.html",
"taggroup": {
"tags": [
{
"is-selectable": true,
"name": "filter[]",
"value": "Church-key Viny",
"label": "Church-key Viny",
"dataset": [
{
"name": "data-content-loader-submit"
}
]
},
{
"is-selectable": true,
"name": "filter[]",
"value": "Flexitarian Retro",
"label": "Flexitarian Retro",
"dataset": [
{
"name": "data-content-loader-submit"
}
]
},
{
"is-selectable": true,
"name": "filter[]",
"value": "Hashtag Echo Park",
"label": "Hashtag Echo Park",
"dataset": [
{
"name": "data-content-loader-submit"
}
]
}
]
}
}
}
}
/* Demo Only Form Combobox */
{
"demo": {
"id": "contentLoaderTarget-form-combobox",
"title": "Form with Combobox",
"form": {
"action": "/includes/contentLoader/form-respond.html",
"combobox": {
"id": "uid",
"placeholder": "Please select",
"noresults": "Keine Treffer",
"options": [
{
"label": "Vinyl pug cardigan",
"id": "my-given-ID",
"name": "my-given-name[]",
"value": 1
},
{
"label": "Flexitarian Retro",
"name": "my-given-name[]",
"value": 2
},
{
"label": "Plaid 8-bit",
"name": "my-given-name[]",
"value": 3,
"is-disabled": true
},
{
"label": "Echo Echo Park",
"name": "my-given-name[]",
"value": 4
},
{
"label": "Echo Vinyl",
"name": "my-given-name[]",
"value": 5
},
{
"label": "Echo",
"value": 6,
"name": "my-given-name[]"
}
],
"label": null,
"is-multiselect": true,
"is-autosend": true
}
}
}
}
/* Demo Only Multiple */
{
"demo": {
"id": "contentLoaderTarget-multiple",
"title": "Successive Loading",
"list": {
"urls": [
"/includes/contentLoader/item-1.html",
"/includes/contentLoader/item-2.html"
]
}
}
}
import contentLoader from "./_contentLoader.script.js";
contentLoader.init();
export default (function (){
const defaults = {
selectors: {
trigger: "[data-content-loader-trigger]",
item: "[data-content-loader-item]",
submit: '[onchange*="this.form.submit"], [data-content-loader-submit]',
},
classes: {
wrapper: "contentLoader-wrapper",
},
actions: [
"append",
"replace",
],
transitionDuration: 200,
};
function init(options) {
let container = document;
if (typeof options === "object" && typeof options.target) {
container = options.target;
}
const triggers = container.querySelectorAll(defaults.selectors.trigger);
for (let i = 0; i < triggers.length; i++) {
new ContentLoader(triggers[i], options);
}
}
const ContentLoader = function(trigger, options) {
this.settings = Object.assign({}, defaults, options);
this.trigger = trigger;
if (! this.trigger.dataset.contentLoaderTarget) {
console.warn("contentLoader: No target defined!");
return false;
}
this.selector = this.trigger.dataset.contentLoaderTarget;
this.targets = document.querySelectorAll(this.selector);
if (this.targets.length === 0) {
console.warn(`contentLoader: No targets match selector "${this.selector}"`);
return false;
}
// Enable nested content loaders by watching changes in targets
this.targets.forEach((target) => {
const observer = new MutationObserver((mutationsList, observer) => {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
init({
target: target,
});
}
}
});
observer.observe(target, { childList: true, subtree: true });
});
// Get urls
this.urls = [];
const form = document.querySelector(`form[data-content-loader-target="${this.selector}"][data-content-loader-url]`);
if (form) {
const loadData = () => {
if (typeof form.dataset.contentLoaderUrl !== "string" || form.dataset.contentLoaderUrl === "") {
console.warn(`contentLoader: No URL in form element defined!`);
return;
}
const formData = new FormData(form),
search = new URLSearchParams(formData),
query = search.toString();
const url = trigger.dataset.contentLoaderUrl + "?" + query;
this.load({
trigger: form,
url: url,
});
};
// Select triggers and bind loading content
const formTriggers = form.querySelectorAll(this.settings.selectors.submit);
formTriggers.forEach((formTrigger) => {
// Remove onChange events to prevent form from being send
formTrigger.removeAttribute("onchange");
formTrigger.addEventListener(
"change",
(e) => {
e.preventDefault();
loadData();
}
);
});
// Handle custom event send by combobox
const comboboxes = this.trigger.querySelectorAll(".combobox");
comboboxes.forEach((combobox) => {
combobox.addEventListener('combobox:send', (event) => {
loadData();
}, false);
});
} else {
const links = document.querySelectorAll(`*[data-content-loader-target="${this.selector}"][data-content-loader-url]`);
if (links.length === 0) {
console.warn(`contentLoader: No links referring "${this.selector}" are found!`);
return false;
}
links.forEach((link) => {
this.urls.push(link.dataset.contentLoaderUrl);
link.removeAttribute("data-content-loader-url");
});
this.trigger.addEventListener(
"click",
(e) => {
e.preventDefault();
this.load({
trigger: e.target,
});
}
);
}
}
ContentLoader.prototype.load = function(options) {
if (typeof options !== "object" || typeof options.trigger !== "object") {
return;
}
if (typeof options.url !== "string") {
options.url = "";
}
let customUrl = (options.url !== "");
if (! customUrl) {
// If trigger itself defines URL, use it, otherwise get next URL
customUrl = (typeof options.trigger.dataset.contentLoaderUrl === "string");
if (customUrl) {
options.url = options.trigger.dataset.contentLoaderUrl;
} else {
if (this.urls.length === 0) {
return;
}
options.url = this.urls.shift();
}
}
if (options.url.match(/^\//)) {
options.url = location.origin + options.url;
}
// Get action
if (typeof options.action !== "string") {
options.action = this.settings.actions[0];
if (customUrl) {
options.action = "replace";
}
}
if (options.trigger.dataset.contentLoaderAction) {
const customAction = options.trigger.dataset.contentLoaderAction;
if (this.settings.actions.includes(customAction)) {
options.action = customAction;
} else {
console.warn(`contentLoader: Action "${customAction}" is not defined.`);
}
}
// Load data
fetch(options.url)
.then(response => response.text())
.then(html => {
this.append(html, options.action);
})
.catch(error => {
console.error(`contentLoader: ${error}`);
})
.finally(() => {
});
}
ContentLoader.prototype.append = function(data, action) {
if (typeof action !== "string") {
action = this.settings.actions[0];
}
this.targets.forEach((target) => {
if (action === "replace") {
const isAnimated = (typeof target.dataset.contentLoaderAnimated === "string" && target.dataset.contentLoaderAnimated === "true");
if (isAnimated) {
this.animate(target, data);
} else {
target.innerHTML = data;
}
} else {
const container = document.createElement("div");
container.innerHTML = data;
const children = Array.from(container.children);
children.forEach((child) => {
target.appendChild(child);
});
}
});
if (this.urls.length === 0) {
this.disable();
}
}
ContentLoader.prototype.animate = function(target, data) {
if (typeof this.timeout !== "undefined") {
clearTimeout(this.timeout);
}
// Add structure for animation
let structureCreated = false,
item = target.querySelector(`:scope > ${this.settings.selectors.item}`);
if (! item) {
const attributeName = this.settings.selectors.item.replace(/[\[\]]/g, "");
item = document.createElement("div");
item.setAttribute(attributeName, "");
item.innerHTML = target.innerHTML;
target.innerHTML = "";
target.appendChild(item);
structureCreated = true;
}
let wrapper = item.querySelector(`:scope > .${this.settings.classes.wrapper}`);
if (! wrapper) {
wrapper = document.createElement("div");
wrapper.classList.add(this.settings.classes.wrapper);
wrapper.innerHTML = item.innerHTML;
item.innerHTML = "";
item.appendChild(wrapper);
structureCreated = true;
}
// Predict height
function getContentBoxHeight(element) {
const style = window.getComputedStyle(element),
paddingTop = parseFloat(style.paddingTop),
paddingBottom = parseFloat(style.paddingBottom),
itemBoxHeight = element.clientHeight - (paddingTop + paddingBottom);
return Math.round(itemBoxHeight);
}
const height = target.offsetHeight,
padding = height - getContentBoxHeight(target);
target.style.height = `${height}px`;
let delay = 0;
if (structureCreated) {
delay = 50;
}
setTimeout(
() => {
item.setAttribute("aria-hidden", true);
},
delay
);
this.timeout = setTimeout(
() => {
wrapper.innerHTML = data;
this.predictInnerHeight(item).then((innerHeight) => {
const offsetHeight = innerHeight + padding;
target.style.height = `${offsetHeight}px`;
item.setAttribute("aria-hidden", false);
this.timeout = setTimeout(
() => {
target.style.height = null;
},
this.duration
);
});
},
this.settings.transitionDuration + delay
);
}
ContentLoader.prototype.predictInnerHeight = function(element) {
return new Promise((resolve, reject) => {
// Item height
element.style.visibility = "hidden";
element.style.transitionDuration = "0s";
element.setAttribute("aria-hidden", false);
const height = element.offsetHeight;
setTimeout(() => {
element.setAttribute("aria-hidden", true);
setTimeout(() => {
element.style.visibility = null;
element.style.transitionDuration = null;
resolve(height);
}, 10);
}, 10);
});
}
ContentLoader.prototype.disable = function(data) {
const triggers = document.querySelectorAll(defaults.selectors.trigger + `[data-content-loader-target="${this.selector}"]`);
for (let i = 0; i < triggers.length; i++) {
triggers[i].disabled = "disabled";
}
}
return {
init: init
}
})();
@import "_contentLoader.styles";
%contentLoader {
&-trigger {
&[disabled] {
display: none;
}
}
}
.contentLoader {
@extend %contentLoader;
}
button[data-content-loader-trigger] {
@extend %contentLoader-trigger;
}
.contentLoader-wrapper {
$bleeding: 24px; // To prevent overflowing content (e.g. box shadows) from being cut off
padding: $bleeding;
margin: (-1 * $bleeding);
overflow: hidden;
opacity: 1;
transition: inherit;
transition-property: opacity;
> *:first-child {
margin-top: 0 !important;
}
}
[data-content-loader-item] {
margin-top: 0 !important;
&[aria-hidden="true"] {
display: none;
.contentLoader-wrapper {
opacity: 0;
}
}
}
[data-content-loader-animated="true"] {
transition-duration: 200ms;
transition-property: height;
overflow: hidden;
[data-content-loader-item] {
display: grid;
grid-template-rows: 1fr;
transition: inherit;
transition-delay: 0;
transition-property: grid-template-rows;
transition-timing-function: easing(cubic);
&[aria-hidden="true"] {
grid-template-rows: 0fr;
}
}
}