From a4908bfed7eeef75f737cc41f880621f8e4e571c Mon Sep 17 00:00:00 2001 From: Florin Raducan Date: Wed, 6 Nov 2024 16:28:55 +0200 Subject: [PATCH] Global Cleanup and Fixes --- blocks/accordion/accordion.js | 20 +- blocks/breadcrumbs/breadcrumbs.js | 33 +- blocks/button/button.js | 25 +- blocks/card/card.js | 4 +- .../developers-content/developers-content.js | 71 +-- blocks/external/external.css | 7 - blocks/external/external.js | 21 - blocks/footer/footer.css | 5 - blocks/footer/footer.js | 28 +- blocks/grid-item/grid-item.js | 19 +- blocks/grid/grid.css | 13 +- blocks/grid/grid.js | 40 +- blocks/header/header.js | 17 +- blocks/icon/icon.css | 4 + blocks/icon/icon.js | 58 +- blocks/image/image.css | 3 - blocks/image/image.js | 43 -- blocks/mermaid/mermaid.js | 3 +- blocks/navigation/navigation.js | 38 +- blocks/popup-trigger/popup-trigger.js | 26 +- blocks/popup/popup.js | 19 +- blocks/router/router.js | 3 +- blocks/swaggerui/swaggerui.js | 3 +- blocks/theming/theming.js | 58 +- scripts/component-base.js | 507 +++++++----------- scripts/component-list/component-list.js | 75 ++- scripts/editor-preview.js | 2 +- scripts/editor.js | 2 +- scripts/index.js | 2 +- scripts/libs.js | 207 ++++--- scripts/libs/external-config.js | 17 +- scripts/render/dom-manipulations.js | 1 + scripts/render/dom-reducers.js | 34 +- scripts/render/dom-reducers.preview.js | 36 +- scripts/render/dom-utils.js | 18 +- scripts/render/dom.js | 80 +-- styles/styles.css | 59 +- 37 files changed, 711 insertions(+), 890 deletions(-) delete mode 100644 blocks/external/external.css delete mode 100644 blocks/external/external.js delete mode 100644 blocks/image/image.css delete mode 100644 blocks/image/image.js diff --git a/blocks/accordion/accordion.js b/blocks/accordion/accordion.js index ac3bf1da..cab0fe39 100644 --- a/blocks/accordion/accordion.js +++ b/blocks/accordion/accordion.js @@ -1,23 +1,11 @@ import ComponentBase from '../../scripts/component-base.js'; +import { componentList } from '../../scripts/component-list/component-list.js'; export default class Accordion extends ComponentBase { - dependencies = ['icon']; + dependencies = componentList.accordion.module.dependencies; - extendNestedConfig() { - return [ - ...super.extendNestedConfig(), - { - button: { - componentName: 'button', - loaderConfig: { - targetsSelectorsPrefix: ':scope > :is(:nth-child(even))', - }, - }, - }, - ]; - } - - ready() { + init() { + super.init(); this.setAttribute('role', 'navigation'); let children = Array.from(this.children); children = children.map((child) => { diff --git a/blocks/breadcrumbs/breadcrumbs.js b/blocks/breadcrumbs/breadcrumbs.js index 67a12886..07ac7822 100644 --- a/blocks/breadcrumbs/breadcrumbs.js +++ b/blocks/breadcrumbs/breadcrumbs.js @@ -1,32 +1,15 @@ import ComponentBase from '../../scripts/component-base.js'; -import { getMeta, metaTags } from '../../scripts/libs.js'; +import { getMeta, metaTags, capitalizeCase } from '../../scripts/libs.js'; export default class Breadcrumbs extends ComponentBase { - static loaderConfig = { - ...ComponentBase.loaderConfig, - targetsSelectors: 'main > div:first-child', - targetsSelectorsLimit: 1, + attributesValues = { + all: { + class: ['full-width'], + }, }; - nestedComponentsConfig = {}; - - extendConfig() { - return [ - ...super.extendConfig(), - { - contentFromTargets: false, - addToTargetMethod: 'replaceWith', - targetsAsContainers: { - addToTargetMethod: 'prepend', - contentFromTargets: false, - }, - }, - ]; - } - - connected() { - this.classList.add('full-width'); - this.classList.add('breadcrumbs'); + init() { + super.init(); const { origin, pathname } = window.location; let breadcrumbRoot = getMeta(metaTags.breadcrumbRoot.metaName); breadcrumbRoot = breadcrumbRoot?.startsWith('/') ? breadcrumbRoot : `/${breadcrumbRoot}`; @@ -52,7 +35,7 @@ export default class Breadcrumbs extends ComponentBase { capitalize(string) { return string .split('-') - .map((str) => str.charAt(0).toUpperCase() + str.slice(1)) + .map((str) => capitalizeCase(str)) .join(' '); } } diff --git a/blocks/button/button.js b/blocks/button/button.js index 43fade07..80888205 100644 --- a/blocks/button/button.js +++ b/blocks/button/button.js @@ -1,21 +1,10 @@ import ComponentBase from '../../scripts/component-base.js'; export default class Button extends ComponentBase { - static loaderConfig = { - ...ComponentBase.loaderConfig, - targetsSelectors: ':is(p,div):has(> a[href]:only-child)', - selectorTest: (el) => el.childNodes.length === 1, - }; - - nestedComponentsConfig = {}; - extendConfig() { return [ ...super.extendConfig(), { - targetsAsContainers: { - addToTargetMethod: 'append', - }, selectors: { anchor: ':scope > a', ariaText: ':scope > a:has(> raqn-icon, > .icon) > strong', @@ -24,23 +13,13 @@ export default class Button extends ComponentBase { ]; } - connected() { - this.initAsBlock(); + init() { + super.init(); this.queryElements(); this.wrapText(); this.addAriaText(); } - initAsBlock() { - if (!this.isInitAsBlock) return; - const anchor = this.querySelector('a'); - this.innerHTML = ''; - if (!anchor) { - throw new Error(`No anchor found in the "${this.componentName}" block`); - } - this.append(anchor); - } - wrapText() { const { anchor, ariaText } = this.elements; const wrap = document.createElement('span'); diff --git a/blocks/card/card.js b/blocks/card/card.js index 7a956d20..5f0df53a 100644 --- a/blocks/card/card.js +++ b/blocks/card/card.js @@ -25,9 +25,9 @@ export default class Card extends ComponentBase { }, }; - ready() { + init() { + super.init(); this.eager = parseInt(this.dataset.eager || 0, 10); - this.classList.add('inner'); if (this.eager) { eagerImage(this, this.eager); } diff --git a/blocks/developers-content/developers-content.js b/blocks/developers-content/developers-content.js index 6624e081..7be24cf9 100644 --- a/blocks/developers-content/developers-content.js +++ b/blocks/developers-content/developers-content.js @@ -3,27 +3,8 @@ import ComponentBase from '../../scripts/component-base.js'; const sitePathPrefix = window.location.hostname === 'docs.raqn.io' ? '/developers' : ''; export default class DeveloperToc extends ComponentBase { - static loaderConfig = { - ...ComponentBase.loaderConfig, - targetsSelectors: 'main > div:first-child', - targetsSelectorsLimit: 1, - }; - - extendConfig() { - return [ - ...super.extendConfig(), - { - contentFromTargets: false, - addToTargetMethod: 'replaceWith', - targetsAsContainers: { - addToTargetMethod: 'prepend', - contentFromTargets: false, - }, - }, - ]; - } - - ready() { + init() { + super.init(); this.generateTablesOfContent(); } @@ -32,7 +13,7 @@ export default class DeveloperToc extends ComponentBase { } toLink(path) { - if(window.location.host.startsWith('localhost') || window.location.host.search(/\.aem\.(page|live)/) > 0) { + if (window.location.host.startsWith('localhost') || window.location.host.search(/\.aem\.(page|live)/) > 0) { return path; } return `${sitePathPrefix}${path}`; @@ -40,13 +21,13 @@ export default class DeveloperToc extends ComponentBase { async loadPageHierarchy() { const response = await fetch(`${sitePathPrefix}/query-index.json`); - if(!response.ok) return []; + if (!response.ok) return []; const json = await response.json(); const pageHierarchy = []; - const pageHierarchyObject = { children:pageHierarchy }; + const pageHierarchyObject = { children: pageHierarchy }; let currentNode; - json.data.forEach(page => { + json.data.forEach((page) => { const segments = page.path.split('/').slice(1); let currentParent = pageHierarchyObject; let nodePath = ''; @@ -60,12 +41,12 @@ export default class DeveloperToc extends ComponentBase { active: window.location.pathname.startsWith(`${sitePathPrefix}${nodePath}`), children: [], }; - if(nodePath === page.path) { + if (nodePath === page.path) { node.page = page; - if(this.isIndex(node)) { + if (this.isIndex(node)) { currentParent.link = page.path; } - if(!currentNode && node.active) { + if (!currentNode && node.active) { currentNode = node; } } @@ -77,16 +58,16 @@ export default class DeveloperToc extends ComponentBase { const postProcessHierarchy = (node) => { node.children.sort((a, b) => a.segment.localeCompare(b.segment)); - if(!node.page && !node.link) { + if (!node.page && !node.link) { const firstChildPage = node.children.find((child) => child.page); - if(firstChildPage) { + if (firstChildPage) { node.link = firstChildPage.page.path; } } node.children.forEach((child) => postProcessHierarchy(child)); }; postProcessHierarchy(pageHierarchyObject); - + return [pageHierarchy, currentNode]; } @@ -98,20 +79,22 @@ export default class DeveloperToc extends ComponentBase { } generateProjects(org) { - return org.children.map((project) => { - const h2 = document.createElement('h2'); - h2.innerText = `${org.segment} - ${project.segment}`; - return `
  • ${h2.outerHTML} -
  • `; - }).join(''); + return org.children + .map((project) => { + const h2 = document.createElement('h2'); + h2.innerText = `${org.segment} - ${project.segment}`; + return `
  • ${h2.outerHTML} +
  • `; + }) + .join(''); } generatePages(node) { - if(this.isIndex(node)) return ''; + if (this.isIndex(node)) return ''; const link = node.link || node.page?.path; const li = document.createElement('li'); - if(link) { + if (link) { const a = document.createElement('a'); a.href = this.toLink(link); a.innerText = node.segment; @@ -121,17 +104,17 @@ export default class DeveloperToc extends ComponentBase { } const childrenHTML = node.children.map((child) => this.generatePages(child)).join(''); - if(childrenHTML) { + if (childrenHTML) { const ul = document.createElement('ul'); ul.innerHTML = childrenHTML; li.appendChild(ul); } - + return li.outerHTML; } - findRepositoryRoot(node){ - if(node.children.length === 1 && !node.children[0].page) { + findRepositoryRoot(node) { + if (node.children.length === 1 && !node.children[0].page) { return this.findRepositoryRoot(node.children[0]); } return node; @@ -145,7 +128,7 @@ export default class DeveloperToc extends ComponentBase { let tocs = ``; - if(currentRepository && currentNode) { + if (currentRepository && currentNode) { const h2 = document.createElement('h2'); h2.innerText = `${currentOrg.segment} - ${currentProject.segment} - ${currentRepository.segment}`; const root = this.findRepositoryRoot(currentRepository); diff --git a/blocks/external/external.css b/blocks/external/external.css deleted file mode 100644 index cfa763db..00000000 --- a/blocks/external/external.css +++ /dev/null @@ -1,7 +0,0 @@ -raqn-external { - display: none; -} - -raqn-external[initialized='true'] { - display: block; -} diff --git a/blocks/external/external.js b/blocks/external/external.js deleted file mode 100644 index 34f6bf83..00000000 --- a/blocks/external/external.js +++ /dev/null @@ -1,21 +0,0 @@ -import ComponentBase from '../../scripts/component-base.js'; - -export default class External extends ComponentBase { - static observedAttributes = ['data-external', 'data-folder']; - - link = false; - - get external() { - const link = this.querySelector('a'); - if (link) { - this.link = link.href; - } - return this.link; - } - - set external(value) { - if (value !== '') { - this.link = value; - } - } -} diff --git a/blocks/footer/footer.css b/blocks/footer/footer.css index 067f7ddc..0b1e4a41 100644 --- a/blocks/footer/footer.css +++ b/blocks/footer/footer.css @@ -19,11 +19,6 @@ raqn-footer ul li a { } @media screen and (min-width: 1024px) { - raqn-footer { - display: grid; - grid-template-columns: auto 20vw; - } - raqn-footer ul li a { padding: 10px 1.2em; border-inline-end: 1px solid var(--text); diff --git a/blocks/footer/footer.js b/blocks/footer/footer.js index e7f78b56..7c097fc0 100644 --- a/blocks/footer/footer.js +++ b/blocks/footer/footer.js @@ -4,31 +4,11 @@ import { getMeta, metaTags } from '../../scripts/libs.js'; const metaFooter = getMeta(metaTags.footer.metaName); export default class Footer extends ComponentBase { - static loaderConfig = { - ...ComponentBase.loaderConfig, - loaderStopInit() { - return metaFooter === false; - }, - }; - fragmentPath = `${metaFooter}.plain.html`; - extendConfig() { - return [ - ...super.extendConfig(), - { - addToTargetMethod: 'append', - }, - ]; - } - - ready() { - const child = this.children[0]; - if (!child) return; - child.replaceWith(...child.children); - this.nav = this.querySelector('ul'); - this.nav?.setAttribute('role', 'navigation'); - this.classList.add('full-width'); - this.classList.add('horizontal'); + init() { + super.init(); + this.elements.nav = this.querySelector('ul'); + this.elements.nav?.setAttribute('role', 'navigation'); } } diff --git a/blocks/grid-item/grid-item.js b/blocks/grid-item/grid-item.js index 81d3e3c8..7777a15c 100644 --- a/blocks/grid-item/grid-item.js +++ b/blocks/grid-item/grid-item.js @@ -2,7 +2,6 @@ import ComponentBase from '../../scripts/component-base.js'; export default class GridItem extends ComponentBase { static observedAttributes = [ - 'data-level', 'data-order', 'data-sticky', 'data-column', @@ -12,20 +11,7 @@ export default class GridItem extends ComponentBase { 'data-align', ]; - nestedComponentsConfig = {}; - - attributesValues = { - all: { - data: { - level: 1, - }, - }, - }; - - setDefaults() { - super.setDefaults(); - this.gridParent = null; - } + gridParent = null; get siblingsItems() { return this.gridParent.gridItems.filter((x) => x !== this); @@ -48,7 +34,8 @@ export default class GridItem extends ComponentBase { } } - connected() { + init() { + super.init(); this.gridParent ??= this.parentElement; } diff --git a/blocks/grid/grid.css b/blocks/grid/grid.css index 1b271aa4..3948be10 100644 --- a/blocks/grid/grid.css +++ b/blocks/grid/grid.css @@ -2,18 +2,13 @@ raqn-grid { /* Set to initial to prevent inheritance for nested grids */ --grid-gap: initial; --grid-height: initial; - --grid-width: 100%; + --grid-width: initial; --grid-justify-items: initial; --grid-align-items: initial; --grid-justify-content: initial; --grid-align-content: initial; - --grid-columns: initial; - --grid-rows: initial; - --grid-auto-columns: initial; - --grid-auto-rows: initial; - --grid-tpl-areas: initial; - --grid-tpl-columns: repeat(var(--grid-columns, 2), 1fr); - --grid-tpl-rows: repeat(var(--grid-rows, 0), 1fr); + --grid-template-columns: initial; + --grid-template-rows: initial; --grid-background: var(--background, black); --grid-color: var(--text, white); @@ -21,7 +16,7 @@ raqn-grid { /* defaults to 2 columns */ grid-template-columns: var(--grid-template-columns, 1fr 1fr); - grid-template-rows: var(--grid-template-rows, 1fr); + grid-template-rows: var(--grid-template-rows, auto); gap: var(--grid-gap, 20px); justify-items: var(--grid-justify-items); align-items: var(--grid-align-items); diff --git a/blocks/grid/grid.js b/blocks/grid/grid.js index 6e5a5c25..13bbb735 100644 --- a/blocks/grid/grid.js +++ b/blocks/grid/grid.js @@ -1,48 +1,14 @@ import ComponentBase from '../../scripts/component-base.js'; -import { flat, stringToJsVal } from '../../scripts/libs.js'; +import { componentList } from '../../scripts/component-list/component-list.js'; +import { stringToJsVal } from '../../scripts/libs.js'; export default class Grid extends ComponentBase { // only one attribute is observed rest is set as css variables directly static observedAttributes = ['data-reverse']; - attributesValues = { - all: { - grid: { - template: { - columns: 'repeat(auto-fill, 200px)', - rows: 'auto', - }, - height: 'initial', - }, - }, - }; - - // use `grid` item as action from component base and apply those as css variables - // dinamic from {@link ../scripts/component-base.js:runConfigsByViewports} - // EG ${viewport}-grid-${attr}-"${value}" - applyGrid(grid) { - const f = flat(grid); - Object.keys(f).forEach((key) => { - this.style.setProperty(`--grid-${key}`, f[key]); - }); - } - - // for backwards compatibility - applyData(data) { - ['columns', 'rows'].forEach((key) => { - if (data[key]) { - if (data.template) { - data.template[key] = data[key]; - } else { - data.template = { [key]: data[key] }; - } - } - }); - this.applyGrid(data); - } + dependencies = componentList.grid.module.dependencies; async onAttributeReverseChanged({ oldValue, newValue }) { - // await for initialization because access to this.gridItems is required; await this.initialization; if (oldValue === newValue) return; diff --git a/blocks/header/header.js b/blocks/header/header.js index 335f228c..030dea66 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -1,28 +1,20 @@ import ComponentBase from '../../scripts/component-base.js'; import { eagerImage, getMeta, metaTags } from '../../scripts/libs.js'; +import { componentList } from '../../scripts/component-list/component-list.js'; const metaHeader = getMeta(metaTags.header.metaName); export default class Header extends ComponentBase { - static loaderConfig = { - ...ComponentBase.loaderConfig, - loaderStopInit() { - return metaHeader === false; - }, - }; + dependencies = componentList.header.module.dependencies; attributesValues = { all: { - class: { - color: 'primary', - }, + class: ['color-primary'], }, }; fragmentPath = `${metaHeader}.plain.html`; - dependencies = ['navigation', 'image']; - extendConfig() { return [ ...super.extendConfig(), @@ -32,7 +24,8 @@ export default class Header extends ComponentBase { ]; } - connected() { + async init() { + super.init(); eagerImage(this, 1); } } diff --git a/blocks/icon/icon.css b/blocks/icon/icon.css index 2bc09b42..40294584 100644 --- a/blocks/icon/icon.css +++ b/blocks/icon/icon.css @@ -1,3 +1,7 @@ +#raqn-svg-sprite { + display: none; +} + raqn-icon { display: inline-flex; text-align: center; diff --git a/blocks/icon/icon.js b/blocks/icon/icon.js index 7b781b81..45ff1526 100644 --- a/blocks/icon/icon.js +++ b/blocks/icon/icon.js @@ -1,8 +1,18 @@ import ComponentBase from '../../scripts/component-base.js'; -import { flatAsValue, isObject, stringToJsVal, getMeta, metaTags } from '../../scripts/libs.js'; +import { stringToJsVal, getMeta, metaTags } from '../../scripts/libs.js'; const metaIcons = getMeta(metaTags.icons.metaName); +const sprite = (function setupSprite() { + let svgSprite = document.getElementById('raqn-svg-sprite'); + if (!svgSprite) { + svgSprite = document.createElement('div'); + svgSprite.id = 'raqn-svg-sprite'; + document.body.append(svgSprite); + } + return svgSprite; +})(); + export default class Icon extends ComponentBase { static observedAttributes = ['data-active', 'data-icon']; @@ -10,6 +20,16 @@ export default class Icon extends ComponentBase { #activeIcon = null; + svgSprite = sprite; + + attributesValues = { + all: { + attribute: { + 'aria-hidden': 'true', + }, + }, + }; + get cache() { window.ICONS_CACHE ??= {}; return window.ICONS_CACHE; @@ -19,47 +39,11 @@ export default class Icon extends ComponentBase { return stringToJsVal(this.dataset.active) === true; } - extendConfig() { - return [ - ...super.extendConfig(), - { - contentFromTargets: false, - }, - ]; - } - - onInit() { - this.setupSprite(); - } - - setupSprite() { - this.svgSprite = document.getElementById('franklin-svg-sprite'); - if (!this.svgSprite) { - this.svgSprite = document.createElement('div'); - this.svgSprite.id = 'franklin-svg-sprite'; - document.body.append(this.svgSprite); - } - } - - setDefaults() { - super.setDefaults(); - this.nestedComponentsConfig = {}; - } - iconUrl(iconName) { const path = `${metaIcons}`; return `${path}/${iconName}.svg`; } - async connected() { - this.setAttribute('aria-hidden', 'true'); - } - - // ${viewport}-icon-${value} or icon-${value} - applyIcon(icon) { - this.dataset.icon = isObject(icon) ? flatAsValue(icon) : icon; - } - // Same icon component can be reused with any other icons just by changing the attribute async onAttributeIconChanged({ oldValue, newValue }) { if (oldValue === newValue) return; diff --git a/blocks/image/image.css b/blocks/image/image.css deleted file mode 100644 index e34c3df2..00000000 --- a/blocks/image/image.css +++ /dev/null @@ -1,3 +0,0 @@ -raqn-image a { - display: block; -} \ No newline at end of file diff --git a/blocks/image/image.js b/blocks/image/image.js deleted file mode 100644 index 74aa774a..00000000 --- a/blocks/image/image.js +++ /dev/null @@ -1,43 +0,0 @@ -import ComponentBase from '../../scripts/component-base.js'; - -// Not supported as a block -export default class Image extends ComponentBase { - static loaderConfig = { - ...ComponentBase.loaderConfig, - targetsSelectors: 'p:has(>picture:only-child) + p:has(> em:only-child > a:only-child)', - selectorTest: (el) => [el.childNodes.length, el.childNodes[0].childNodes.length].every((len) => len === 1), - targetsAsContainers: true, - }; - - nestedComponentsConfig = {}; - - extendConfig() { - return [ - ...super.extendConfig(), - { - addToTargetMethod: 'append', - targetsAsContainers: { - addToTargetMethod: 'append', - }, - }, - ]; - } - - connected() { - this.createLinkedImage(); - } - - createLinkedImage() { - if (!this.children) return; - const em = this.firstElementChild; - const anchor = em.firstElementChild; - const pictureParent = this.parentElement.previousElementSibling; - const picture = pictureParent.firstElementChild; - anchor.setAttribute('aria-label', anchor.textContent); - anchor.innerHTML = ''; - anchor.append(picture); - this.append(anchor); - em.remove(); - pictureParent.remove(); - } -} diff --git a/blocks/mermaid/mermaid.js b/blocks/mermaid/mermaid.js index e7209660..543f3572 100644 --- a/blocks/mermaid/mermaid.js +++ b/blocks/mermaid/mermaid.js @@ -7,7 +7,8 @@ mermaid.initialize({ }); export default class Mermaid extends ComponentBase { - ready() { + init() { + super.init(); const code = this.querySelector('code'); if(!code) { throw new Error('Cannot initialize mermaid without code content.'); diff --git a/blocks/navigation/navigation.js b/blocks/navigation/navigation.js index bab70441..b2075c8a 100644 --- a/blocks/navigation/navigation.js +++ b/blocks/navigation/navigation.js @@ -1,20 +1,23 @@ -import { blockBodyScroll } from '../../scripts/libs.js'; +import { blockBodyScroll, loadAndDefine } from '../../scripts/libs.js'; +import { componentList } from '../../scripts/component-list/component-list.js'; import ComponentBase from '../../scripts/component-base.js'; export default class Navigation extends ComponentBase { static observedAttributes = ['data-menu-icon', 'data-item-icon', 'data-compact']; - dependencies = ['icon', 'accordion']; + active = {}; + + isActive = false; + + #navContentInit = false; + + navCompactedContentInit = false; attributesValues = { all: { data: { - menu: { - icon: 'menu__close', - }, - item: { - icon: 'chevron-right', - }, + 'menu-icon': 'menu__close', + 'item-icon': 'chevron-right', }, }, m: { @@ -34,15 +37,8 @@ export default class Navigation extends ComponentBase { }, }; - setDefaults() { - super.setDefaults(); - this.active = {}; - this.isActive = false; - this.navContentInit = false; - this.navCompactedContentInit = false; - } - - async ready() { + async init() { + super.init(); this.navContent = this.querySelector('ul'); this.innerHTML = ''; this.navCompactedContent = this.navContent.cloneNode(true); // the clone need to be done before `this.navContent` is modified @@ -60,8 +56,8 @@ export default class Navigation extends ComponentBase { } setupNav() { - if (!this.navContentInit) { - this.navContentInit = true; + if (!this.#navContentInit) { + this.#navContentInit = true; this.setupClasses(this.navContent); } this.navButton?.remove(); @@ -70,6 +66,7 @@ export default class Navigation extends ComponentBase { async setupCompactedNav() { if (!this.navCompactedContentInit) { + loadAndDefine(componentList.accordion); this.navCompactedContentInit = true; this.setupClasses(this.navCompactedContent, true); this.navCompactedContent.addEventListener('click', (e) => this.activate(e)); @@ -187,8 +184,7 @@ export default class Navigation extends ComponentBase { closeLevels(activeLevel, currentLevel = 1) { let whileCurrentLevel = currentLevel; while (whileCurrentLevel <= activeLevel) { - const activeElem = this.active[currentLevel]; - + const activeElem = this.active[whileCurrentLevel]; activeElem.classList.remove('active'); const accordion = activeElem.querySelector('raqn-accordion'); const control = accordion.querySelector('.accordion-control'); diff --git a/blocks/popup-trigger/popup-trigger.js b/blocks/popup-trigger/popup-trigger.js index 2eefaf73..6f4af659 100644 --- a/blocks/popup-trigger/popup-trigger.js +++ b/blocks/popup-trigger/popup-trigger.js @@ -1,18 +1,15 @@ import ComponentBase from '../../scripts/component-base.js'; import { componentList } from '../../scripts/component-list/component-list.js'; -import { popupState } from '../../scripts/libs.js'; -import { loadModules } from '../../scripts/render/dom-reducers.js'; +import { popupState, loadAndDefine } from '../../scripts/libs.js'; export default class PopupTrigger extends ComponentBase { static observedAttributes = ['data-active', 'data-action']; - static loaderConfig = { - ...ComponentBase.loaderConfig, - targetsSelectors: 'a:is([href*="#popup-trigger"],[href*="#popup-close"])', - targetsAsContainers: true, - }; + isClosePopupTrigger = false; - nestedComponentsConfig = {}; + ariaLabel = null; + + popupSourceUrl = null; get isActive() { return this.dataset.active === 'true'; @@ -31,16 +28,10 @@ export default class PopupTrigger extends ComponentBase { ]; } - setDefaults() { - super.setDefaults(); - this.isClosePopupTrigger = false; - this.ariaLabel = null; - this.popupSourceUrl = null; - } - - onInit() { + init() { this.setAction(); this.queryElements(); + this.addListeners(); } setAction() { @@ -112,8 +103,7 @@ export default class PopupTrigger extends ComponentBase { } async createPopup() { - const { popup } = componentList; - loadModules(null, { popup }); + loadAndDefine(componentList.popup); const popupEl = document.createElement('raqn-popup'); popupEl.dataset.action = this.popupSourceUrl; diff --git a/blocks/popup/popup.js b/blocks/popup/popup.js index df7515e7..2fb59ae4 100644 --- a/blocks/popup/popup.js +++ b/blocks/popup/popup.js @@ -18,7 +18,11 @@ export default class Popup extends ComponentBase { configPopupAttributes = ['data-type', 'data-size', 'data-offset', 'data-height']; - dependencies = ['icon']; + /** + * Optional special property to set a reference to a popupTrigger element which controls this popup. + * This will automatically control the states of the popupTrigger based on popup interaction. + */ + popupTrigger = null; get isActive() { return this.dataset.active !== 'true'; @@ -28,10 +32,6 @@ export default class Popup extends ComponentBase { return [ ...super.extendConfig(), { - contentFromTargets: false, - targetsAsContainers: { - contentFromTargets: false, - }, showCloseBtn: true, selectors: { popupBase: '.popup__base', @@ -53,15 +53,6 @@ export default class Popup extends ComponentBase { ]; } - setDefaults() { - super.setDefaults(); - /** - * Optional special property to set a reference to a popupTrigger element which controls this popup. - * This will automatically control the states of the popupTrigger based on popup interaction. - */ - this.popupTrigger = null; - } - setBinds() { this.closeOnEsc = this.closeOnEsc.bind(this); } diff --git a/blocks/router/router.js b/blocks/router/router.js index 28c3e96b..8d77bb18 100644 --- a/blocks/router/router.js +++ b/blocks/router/router.js @@ -13,7 +13,8 @@ export default class Router extends ComponentBase { return `${url}.plain.html`; } - ready() { + init() { + super.init(); document.addEventListener( 'click', (event) => { diff --git a/blocks/swaggerui/swaggerui.js b/blocks/swaggerui/swaggerui.js index 31f298ef..23136e07 100755 --- a/blocks/swaggerui/swaggerui.js +++ b/blocks/swaggerui/swaggerui.js @@ -101,7 +101,8 @@ export default class SwaggerUI extends ComponentBase { this.switchAPI(apiFilter.length > 0 ? apiFilter[0] : window.location.hash); } - async ready() { + async init() { + super.init(); const apiFilter = [...this.querySelectorAll('a')] .map((a) => new URL(a.href).hash) .filter((hash) => hash.length > 0 && hash.indexOf('--') > 0); diff --git a/blocks/theming/theming.js b/blocks/theming/theming.js index 1870e19b..46597f6a 100644 --- a/blocks/theming/theming.js +++ b/blocks/theming/theming.js @@ -15,8 +15,6 @@ import { externalConfig } from '../../scripts/libs/external-config.js'; const k = Object.keys; export default class Theming extends ComponentBase { - componentsConfig = {}; - elements = {}; variations = {}; @@ -49,14 +47,13 @@ export default class Theming extends ComponentBase { ``, ); return `@font-face { - font-display: fallback; - font-family: '${key}'; - src: url('${window.location.origin}/fonts/${data[key].options[type]}'); - ${type === 'italic' ? 'font-style' : 'font-weight'}: ${type}; - } - `; + font-display: fallback; + font-family: '${key}'; + src: url('${window.location.origin}/fonts/${data[key].options[type]}'); + ${type === 'italic' ? 'font-style' : 'font-weight'}: ${type}; +}`; }) - .join(''); + .join('\n'); }) .join(''); } @@ -76,9 +73,8 @@ export default class Theming extends ComponentBase { const query = getMediaQuery(min, max); return ` @media ${query} { - ${callback(obj[bp])} - } - `; + ${callback(obj[bp])} +}`; } // regular return callback(obj[bp]); @@ -108,7 +104,7 @@ export default class Theming extends ComponentBase { } else if (isComponent) { Object.keys(responseData).forEach((key) => { if (key.indexOf(':') === 0 || responseData[key].data.length === 0) return; - this.componentsConfig[key] = this.componentsConfig[key] || {}; + this.componentsConfig[key] ??= {}; this.componentsConfig[key] = readValue(responseData[key].data, this.componentsConfig[key]); }); } else { @@ -121,10 +117,10 @@ export default class Theming extends ComponentBase { defineVariations() { const names = k(this.variations); - const result = names.reduce((a, name) => { + const result = names.reduce((acc, name) => { const unflatted = unFlat(this.variations[name]); return ( - a + + acc + this.reduceViewports(unflatted, (actionData) => { const actions = k(actionData); return actions.reduce((b, action) => { @@ -147,21 +143,19 @@ export default class Theming extends ComponentBase { variablesValues(data, name, prepend = '.') { const f = flat(data); return `${prepend || '.'}${name} { - ${k(f) - .map((key) => `\n--${key}: ${f[key]};`) - .join('')} - } - `; +${k(f) + .map((key) => `--${key}: ${f[key]};`) + .join('\n')} +}\n`; } variablesScopes(data, name, prepend = '.') { const f = flat(data); return `${prepend}${name} { - ${k(f) - .map((key) => `\n${key}: var(--${name}-${key}, ${f[key]});`) - .join('')} - } - `; + ${k(f) + .map((key) => `${key}: var(--${name}-${key}, ${f[key]});`) + .join('\n')} + }`; } renderFont(data, name) { @@ -175,10 +169,20 @@ export default class Theming extends ComponentBase { } async loadFragment() { - const themeConfigs = getMetaGroup(metaTags.themeConfig.metaNamePrefix); + const { themeConfig } = metaTags; + + const themeConfigs = getMetaGroup(themeConfig.metaNamePrefix); const base = getBaseUrl(); await Promise.allSettled( - themeConfigs.map(async ({ name, content }) => { + themeConfigs.map(async ({ name, content, nameWithPrefix }) => { + if (!content.includes(`${themeConfig.fallbackContent}`) && name !== 'fontface') { + // eslint-disable-next-line no-console + console.error( + `The configured "${nameWithPrefix}" config url is not containing a "${themeConfig.fallbackContent}" folder.`, + ); + return {}; + } + const response = name === 'component' ? externalConfig.loadConfig(true) // use the loader to prevent duplicated calls diff --git a/scripts/component-base.js b/scripts/component-base.js index 06168ee0..04adaf45 100644 --- a/scripts/component-base.js +++ b/scripts/component-base.js @@ -2,16 +2,18 @@ import { getBreakPoints, listenBreakpointChange, camelCaseAttr, + capitalizeCase, capitalizeCaseAttr, + isObject, deepMerge, - classToFlat, + deepMergeByType, unFlat, - isObject, - flatAsValue, - flat, + stringToArray, mergeUniqueArrays, + stringToJsVal, runTasks, } from './libs.js'; +import { componentList } from './component-list/component-list.js'; import { externalConfig } from './libs/external-config.js'; import { generateVirtualDom, renderVirtualDom } from './render/dom.js'; import { generalManipulation } from './render/dom-manipulations.js'; @@ -21,7 +23,14 @@ export default class ComponentBase extends HTMLElement { // The order of observedAttributes is the order in which the values from config are added. static observedAttributes = []; - dataAttributesKeys = []; + // dependencies must to be added and checked locally for cases when components are created inside other components + static dependencies = componentList[this.componentName]?.module?.dependencies || []; + + initialization; // set as promise in constructor(); + + initResolvers; // the resolvers for the initialization promise; + + initialized = false; uuid = `gen${crypto.randomUUID().split('-')[0]}`; @@ -31,92 +40,68 @@ export default class ComponentBase extends HTMLElement { overrideExternalConfig = false; - wasInitBeforeConnected = false; + virtualNode = null; fragmentPath = null; fragmentCache = 'default'; - dependencies = []; - - attributesValues = {}; + // Settings which automatically react on breakpoints changes + attributesValues = {}; // add defaults here if no external configId is provided - initOptions = {}; + currentAttrValues = {}; // The values for the current breakpoint. Set automatically. - externalOptions = {}; - - elements = {}; - - childComponents = { - // using the nested feature - nestedComponents: [], - // from inner html blocks - innerComponents: [], - // from inner html blocks - innerGrids: [], - }; - - // set only if content is loaded externally - innerBlocks = []; - - // set only if content is loaded externally - innerGrids = []; + elements = {}; // references to other elements should be saved here initError = null; breakpoints = getBreakPoints(); - // use the extendConfig() method to extend the default config + // All settings which are not in `attributesValues` which might require extension in extended components should be in the config. + // Use the `extendConfig()` method to extend the config config = { - listenBreakpoints: false, - hideOnInitError: true, - hideOnChildrenError: false, - addToTargetMethod: 'replaceWith', - contentFromTargets: true, - targetsAsContainers: { - addToTargetMethod: 'replaceWith', - contentFromTargets: true, + selectors: {}, + classes: { + showLoader: 'show-loader', }, - }; - - // use the extendNestedConfig() method to extend the default config - nestedComponentsConfig = { - image: { - componentName: 'image', + subscriptions: {}, + publishes: {}, + dispatches: { + initialized: (uuid) => `initialized:${uuid}`, }, - button: { - componentName: 'button', + listenBreakpoints: false, + hideOnInitError: true, + // All the component attributes which are not in the `observedAttributes` + knownAttributes: { + configId: 'config-id', + isLoading: 'isloading', + raqnwebcomponent: 'raqnwebcomponent', }, - }; - - static loaderConfig = { - targetsSelectorsPrefix: null, - targetsSelectors: null, - selectorTest: null, // a function to filter elements matched by targetsSelectors - targetsSelectorsLimit: null, - targetsAsContainers: false, - loaderStopInit() { - return false; + // Merge options for non object values in `attributesValues` + attributesMergeMethods: { + '**.class': (a, b) => mergeUniqueArrays(a, b), }, }; - get Handler() { - return window.raqnComponents[this.componentName]; - } + dataAttributesKeys = this.constructor.observedAttributes.flatMap((data) => { + if (!data.startsWith('data-')) return []; + const [, noData] = data.split('data-'); + return { data, noData, noDataCamelCase: camelCaseAttr(noData) }; + }); - get isInitAsBlock() { - return this.initOptions?.target?.classList?.contains(this.componentName); + get configId() { + return this.getAttribute(this.config.knownAttributes.configId); } constructor() { super(); - this.setDefaults(); this.setInitializationPromise(); - this.extendConfigRunner({ config: 'config', method: 'extendConfig' }); - this.extendConfigRunner({ config: 'nestedComponentsConfig', method: 'extendNestedConfig' }); + this.setDefaults(); this.setBinds(); } + /** + * Add any custom properties to the class with a default value or use class fields */ setDefaults() {} setInitializationPromise() { @@ -126,231 +111,134 @@ export default class ComponentBase extends HTMLElement { reject, }; }); - // Promise.withResolvers don't fullfill last 2 versions of Safari - // eg this breaks everything in Safari < 17.4, we need to support. - // const { promise, resolve, reject } = Promise.withResolvers(); - } - - // Using the `method` which returns an array of objects it's easier to extend - // configs when the components are deeply extended with multiple levels of inheritance; - extendConfigRunner({ config, method }) { - const conf = this[method]?.(); - if (!conf.length) return; - this[config] = deepMerge({}, ...conf); - } - - extendConfig() { - return [...(super.extendConfig?.() || []), this.config]; - } - - extendNestedConfig() { - return [...(super.extendNestedConfig?.() || []), this.nestedComponentsConfig]; } + /** + * Use this to bind any method to the class */ setBinds() { this.onBreakpointChange = this.onBreakpointChange.bind(this); } - setDataAttributesKeys() { - const { observedAttributes } = this.constructor; - this.dataAttributesKeys = observedAttributes.map((dataAttr) => { - const [, key] = dataAttr.split('data-'); - - return { - data: dataAttr, - noData: key, - noDataCamelCase: camelCaseAttr(key), - }; - }); - } - - // ! Needs to be called after the element is created; - async init(initOptions) { - try { - await this.Handler; - this.wasInitBeforeConnected = true; - this.initOptions = initOptions || {}; - this.setInitialAttributesValues(); - await this.buildExternalConfig(); - this.runConfigsByViewport(); - this.addDefaultsToNestedConfig(); - // Add extra functionality to be run on init. - await this.onInit(); - this.addContentFromTargetCheck(); - await this.connectComponent(); - } catch (error) { - if (initOptions.throwInitError) { - throw error; - } else { - // eslint-disable-next-line no-console - console.error(`There was an error while initializing the '${this.componentName}' webComponent:`, this, error); - } - } - } - - /** - * When the element was created with data attributes before the ini() method is called - * use the data attr values as default for attributesValues - */ - setInitialAttributesValues() { - const initial = [...this.classList]; - initial.unshift(); // remove the component name - this.initialAttributesValues = classToFlat(initial.splice(1)); - const initialAttributesValues = this.initialAttributesValues || { all: { data: {} } }; - if (this.dataAttributesKeys && !this.dataAttributesKeys.length) { - this.setDataAttributesKeys(); - } else { - this.dataAttributesKeys = this.dataAttributesKeys || []; - } - this.dataAttributesKeys.forEach(({ noData, noDataCamelCase }) => { - const value = this.dataset[noDataCamelCase]; - - if (typeof value === 'undefined') return {}; - const initialValue = unFlat({ [noData]: value }); - if (initialAttributesValues.all && initialAttributesValues.all.data) { - initialAttributesValues.all.data = deepMerge({}, initialAttributesValues.all.data, initialValue); - } - return initialAttributesValues; - }); - - this.attributesValues = deepMerge( - {}, - this.attributesValues, - this.initOptions?.attributesValues || {}, - initialAttributesValues, - ); - } - - async connectComponent() { - if (!this.initOptions.target) return this; - const { targetsAsContainers } = this.initOptions.loaderConfig || {}; - const conf = this.config; - const addToTargetMethod = targetsAsContainers ? conf.targetsAsContainers.addToTargetMethod : conf.addToTargetMethod; - - this.initOptions.target[addToTargetMethod](this); - - return this.initialization; - } - // Build-in method called after the element is added to the DOM. async connectedCallback() { + const { knownAttributes, hideOnInitError, dispatches } = this.config; // Common identifier for raqn web components - this.setAttribute('raqnwebcomponent', ''); - this.setAttribute('isloading', ''); + this.setAttribute(knownAttributes.raqnwebcomponent, ''); + this.setAttribute(knownAttributes.isLoading, 'true'); try { - this.initialized = this.getAttribute('initialized'); this.initSubscriptions(); // must subscribe each type the element is added to the document if (!this.initialized) { - await this.initOnConnected(); this.setAttribute('id', this.uuid); - await this.loadFragment(this.fragmentPath); - await this.connected(); // manipulate/create the html - this.dataAttributesKeys = await this.setDataAttributesKeys(); - this.addListeners(); // html is ready add listeners - await this.ready(); // add extra functionality - this.setAttribute('initialized', true); + await this.onConnected(); this.initialized = true; this.initResolvers.resolve(this); - this.dispatchEvent(new CustomEvent(`initialized:${this.uuid}`, { detail: { element: this } })); + this.dispatchEvent(new CustomEvent(dispatches.initialized(this.uuid), { detail: { element: this } })); + } else { + await this.reConnected(); } } catch (error) { this.initResolvers.reject(error); - this.dispatchEvent(new CustomEvent(`initialized:${this.uuid}`, { detail: { error } })); + this.dispatchEvent(new CustomEvent(dispatches.initialized(this.uuid), { detail: { error } })); this.initError = error; - this.hideWithError(this.config.hideOnInitError, 'has-init-error'); + this.hideWithError(hideOnInitError, 'has-init-error'); // eslint-disable-next-line no-console console.error(`There was an error after the '${this.componentName}' webComponent was connected:`, this, error); } - this.removeAttribute('isloading'); + this.removeAttribute(knownAttributes.isLoading); } - // This allows for components to be initialized as a string by adding them to another element's innerHTML - // The attributes `data-config-name` and `data-config-by-classes` can be used to set the config + /** + * Do not overwrite this method unless absolutely needed. */ + async onConnected() { + await this.initSettings(); + await this.loadFragment(this.fragmentPath); + await this.init(); + } - async initOnConnected() { - if (this.wasInitBeforeConnected) return; - await this.Handler; + /** + * Use this method to add the component's functionality */ + async init() { + await this.addListeners(); + } + + async initSettings() { + this.extendConfigRunner({ field: 'config', method: 'extendConfig' }); this.setInitialAttributesValues(); await this.buildExternalConfig(); - this.runConfigsByViewport(); - this.addDefaultsToNestedConfig(); - // Add extra functionality to be run on init. - await this.onInit(); + this.runConfigsByViewport(); // set the values for current breakpoint } - async buildExternalConfig() { - let configByClasses = mergeUniqueArrays(this.initOptions.configByClasses, this.classList); - // normalize the configByClasses to serializable format - const { byName } = getBreakPoints(); - configByClasses = configByClasses - // remove the first class which is the component name and keep only compound classes - .filter((c, index) => c.includes('-') && index !== 0) - // make sure break points are included in the config - .map((c) => { - const exceptions = ['all', 'config']; - const firstClass = c.split('-')[0]; - const isBreakpoint = Object.keys(byName).includes(firstClass) || exceptions.includes(firstClass); - return isBreakpoint ? c : `all-${c}`; - }); + // Using the `method` which returns an array of objects it's easier to extend + // configs when the components are deeply extended with multiple levels of inheritance; + extendConfigRunner({ field, method }) { + const conf = this[method]?.(); + if (conf.length <= 1) return; + this[field] = deepMerge({}, ...conf); + } - // serialize the configByClasses into a flat object - let values = classToFlat(configByClasses); - - // get the external config - // TODO With the unFlatten approach of setting this.attributesValues there is an increased amount of processing - // each time a viewport changes when we need to flatten again the values - // better approach would be to generate this.attributesValues in the final state needed by each time of data: - // - class - as arrays with unique values - // - data - as flatten values with camel case keys - // - attributes - as flatten values with hyphen separated keys. - // for anything else set them flatten as they come from from external config - const configs = unFlat(await externalConfig.getConfig(this.componentName, values.config)); - - if (this.overrideExternalConfig) { - // Used for preview functionality - values = deepMerge({}, configs, this.attributesValues, values); - } else { - values = deepMerge({}, configs, values); - } + extendConfig() { + return [...(super.extendConfig?.() || []), this.config]; + } - delete values.config; + setInitialAttributesValues() { + const initialAttributesValues = {}; - // add to attributesValues - this.attributesValues = deepMerge({}, this.attributesValues, values); - } + this.classList.remove(this.componentName); + const classes = [...this.classList]; + const { byName } = this.breakpoints; + this.removeAttribute('class'); - addDefaultsToNestedConfig() { - Object.keys(this.nestedComponentsConfig).forEach((key) => { - const defaults = { - targets: [this], - active: true, - loaderConfig: { - targetsAsContainers: true, - }, - }; - this.nestedComponentsConfig[key] = deepMerge({}, defaults, this.nestedComponentsConfig[key]); + /** Add any class prefixed with a breakpoint to `attributesValues` + * Classes without a prefix will be added to the element and + * not handled by `attributesValues` functionality */ + classes.reduce((acc, cls) => { + const isBreakpoint = ['all', ...Object.keys(byName)].includes(cls.split('-')[0]); + if (!isBreakpoint) return this.classList.add(cls) || acc; + + const [breakpoint, ...partCls] = cls.split('-'); + if (partCls[0] === 'class') partCls.shift(); + acc[breakpoint] ??= {}; + acc[breakpoint].class ??= []; + acc[breakpoint].class.push(partCls.join('-')); + + return acc; + }, initialAttributesValues); + + /** + * When the element was created with data attributes + * use the values as default for attributesValues.all */ + this.dataAttributesKeys.forEach(({ noData, noDataCamelCase }) => { + const value = this.dataset[noDataCamelCase]; + + if (typeof value === 'undefined') return; + const initialValue = { [noData]: value }; + initialAttributesValues.all ??= { data: {} }; + initialAttributesValues.all.data = deepMerge({}, initialAttributesValues.all.data, initialValue); }); + + this.attributesValues = deepMergeByType( + this.config.attributesMergeMethods, + {}, + this.attributesValues, + initialAttributesValues, + ); } - addContentFromTargetCheck() { - if (!this.initOptions.target) return; + async buildExternalConfig() { + const unFlatConfig = unFlat(await externalConfig.getConfig(this.componentName, this.configId)); - const { targetsAsContainers } = this.initOptions.loaderConfig; - const { - contentFromTargets, - targetsAsContainers: { contentFromTargets: contentFromTargetsAsContainer }, - } = this.config; - const getContent = targetsAsContainers ? contentFromTargetsAsContainer : contentFromTargets; + // turn classes to array + Object.values(unFlatConfig).forEach((value) => { + if (typeof value.class === 'string') { + value.class = stringToArray(value.class, { divider: ' ' }); + } + }); - if (!getContent) return; - this.addContentFromTarget(); - } + const toMerge = [this.attributesValues, unFlatConfig]; - addContentFromTarget() { - const { target } = this.initOptions; - const { contentFromTargets } = this.config; - if (!contentFromTargets) return; - this.append(...target.childNodes); + if (this.overrideExternalConfig) toMerge.reverse(); + + this.attributesValues = deepMergeByType(this.config.attributesMergeMethods, {}, ...toMerge); } onBreakpointChange(e) { @@ -359,79 +247,85 @@ export default class ComponentBase extends HTMLElement { } } + currentAttributesValues() { + const { name } = this.breakpoints.active; + const currentAttrValues = deepMergeByType( + this.config.attributesMergeMethods, + {}, + this.attributesValues.all, + this.attributesValues[name], + ); + this.currentAttrValues = currentAttrValues; + return currentAttrValues; + } + runConfigsByViewport() { - const { name } = getBreakPoints().active; - const current = deepMerge({}, this.attributesValues.all, this.attributesValues[name]); - this.removeAttribute('class'); - Object.keys(current).forEach((key) => { - const action = `apply${key.charAt(0).toUpperCase() + key.slice(1)}`; + const oldValues = this.currentAttrValues; + const newValues = this.currentAttributesValues(); + const keysArray = mergeUniqueArrays(Object.keys(oldValues), Object.keys(newValues)); + + keysArray.forEach((key) => { + const action = `apply${capitalizeCase(key)}`; if (typeof this[action] === 'function') { - return this[action]?.(current[key]); + this[action]?.({ oldValue: oldValues[key], newValue: newValues[key] }); } - return this.applyClass(current[key]); }); } - // ${viewport}-data-${attr}-"${value}" - applyData(entries) { - // received as {col:{ direction:2 }, columns: 2} - const values = flat(entries); - // transformed into values as {col-direction: 2, columns: 2} - if (!this.dataAttributesKeys) { - this.setDataAttributesKeys(); - } + applyData({ oldValue, newValue }) { // Add only supported data attributes from observedAttributes; // Sometimes the order in which the attributes are set matters. // Control the order by using the order of the observedAttributes. this.dataAttributesKeys.forEach(({ noData, noDataCamelCase }) => { - if (typeof values[noData] !== 'undefined') { - this.dataset[noDataCamelCase] = values[noData]; - } else { + const hasNewVal = typeof newValue?.[noData] !== 'undefined'; + // delete only when needed to minimize the times the attributeChangedCallback is triggered; + if (typeof oldValue?.[noData] !== 'undefined' && !hasNewVal) { delete this.dataset[noDataCamelCase]; } + if (hasNewVal) { + this.dataset[noDataCamelCase] = newValue[noData]; + } }); } - // ${viewport}-class-${value} - applyClass(className) { - // {'color':'primary', 'max':'width'} -> 'color-primary max-width' - - // classes can be serialized as a string or an object - if (isObject(className)) { - // if an object is passed, it's flat and splitted - this.classList.add(...flatAsValue(className).split(' ')); - } else if (className) { - // strings are added as is - this.setAttribute('class', className); - } + applyClass({ oldValue, newValue }) { + if (oldValue?.length) this.classList.remove(...oldValue); + if (newValue?.length) this.classList.add(...newValue); } - // ${viewport}-attribute-${value} + applyAttribute({ oldValue, newValue }) { + [oldValue, newValue].forEach((value, i) => { + if (!isObject(value)) return; + const isOld = i === 0; + const addRemove = isOld ? 'removeAttribute' : 'setAttribute'; - applyAttribute(entries) { - // received as {col:{ direction:2 }, columns: 2} - const values = flat(entries); - // transformed into values as {col-direction: 2, columns: 2} - Object.keys(values).forEach((key) => { - // camelCaseAttr converts col-direction into colDirection - this.setAttribute(key, values[key]); + Object.keys(value).forEach((key) => { + if (isOld && typeof newValue?.[key] !== 'undefined') return; + this[addRemove](key, value[key]); + }); }); } - // ${viewport}-nest-${value} + applyStyle({ oldValue, newValue }) { + [oldValue, newValue].forEach((value, i) => { + if (!isObject(value)) return; + const isOld = i === 0; + const addRemove = isOld ? 'removeProperty' : 'setProperty'; - applyNest(config) { - const names = Object.keys(config); - names.map((key) => { - const instance = document.createElement(`raqn-${key}`); - instance.initOptions.configByClasses = [config[key]]; - - this.cachedChildren = Array.from(this.initOptions.target.children); - this.cachedChildren.forEach((child) => instance.append(child)); - this.append(instance); + Object.keys(value).forEach((key) => { + if (isOld && typeof newValue?.[key] !== 'undefined') return; + this.style[addRemove](key, value[key]); + }); }); } + // TODO handle this part + applySetting(config) { + // delete the setting to run only once on init + delete this.attributesValues.all.setting; + deepMerge(this.config, config); + } + /** * Attributes are assigned before the `connectedCallback` is triggered. * In some cases a check for `this.initialized` inside `onAttribute${capitalizedAttr}Changed` might be required @@ -451,6 +345,12 @@ export default class ComponentBase extends HTMLElement { } } + // to add a loading spinner for the component add 'isloading' attribute to the `observedAttributes` + onAttributeIsloadingChanged({ oldValue, newValue }) { + if (oldValue === newValue) return; + this.classList.toggle(this.config.classes.showLoader, stringToJsVal(newValue) || false); + } + getBreakpointAttrVal(attr) { const { name: activeBrName } = this.breakpoints.active; const attribute = this.attributesValues?.[attr]; @@ -459,7 +359,7 @@ export default class ComponentBase extends HTMLElement { } addListeners() { - if (Object.keys(this.attributesValues).length > 1) { + if (Object.keys(this.attributesValues).length >= 1) { listenBreakpointChange(this.onBreakpointChange); } } @@ -524,11 +424,14 @@ export default class ComponentBase extends HTMLElement { initSubscriptions() {} - onInit() {} + removeSubscriptions() {} - connected() {} + removeListeners() {} - ready() {} + reConnected() {} - disconnectedCallback() {} + disconnectedCallback() { + this.removeSubscriptions(); + this.removeListeners(); + } } diff --git a/scripts/component-list/component-list.js b/scripts/component-list/component-list.js index 6c25c543..90ef9795 100644 --- a/scripts/component-list/component-list.js +++ b/scripts/component-list/component-list.js @@ -1,4 +1,5 @@ -import { previewModule } from '../libs.js'; +import { previewModule, getMeta, metaTags } from '../libs.js'; +import { setPropsAndAttributes, getClassWithPrefix } from '../render/dom-utils.js'; const forPreviewList = await previewModule(import.meta, 'componentList'); @@ -20,15 +21,19 @@ const forPreviewList = await previewModule(import.meta, 'componentList'); * @prop {string} method - The virtualDom mode mutation method found in /scripts/render/dom/dom.js/nodeProxy * e.g." 'append', 'replaceWith' etc. * Or the special value 'replace' which does a simple tag value change. - * @prop {string} method - The virtualDom mutation method found in /scripts/render/dom/dom.js/nodeProxy. e.g." 'append', 'replaceWith' - * @prop {object} module - webComponent module loading configuration. - * @prop {string} module.path - Root relative path to the module folder. - * @prop {boolean} [module.loadJS=true] - Set if the component has js module to load. - * @prop {boolean} [module.loadCSS=true] - Set if the component has css module to load. - * @prop {number} [module.priority] - The order in which the modules will be loaded. + * @prop {number} queryLevel - set te depth level after which the search for the tag will stop. + * Option to optimize the query if it's unnecessary to to query the entire virtual dom. * @prop {configMethod} [filterNode] - Filter method to identify if the node is a match for the component - * if match si more complex and can not be achieve by matching against 'blockName' * @prop {configMethod} [transform] - Method to modify the node in place or return a new node + * if match si more complex and can not be achieve by matching against 'blockName' + * @prop {object} module - webComponent module loading configuration. + * @prop {string} module.path - Root relative path to the module folder. + * @prop {boolean} [module.loadJS=true] - Set if the component has js module to load. + * @prop {boolean} [module.loadCSS=true] - Set if the component has css module to load. + * @prop {number} [module.priority] - The order in which the modules will be loaded. + * @prop {number} [module.dependencies] - An array of 'blockName's for which the modules will be loaded together with the current one. + * If a dependency is breakpoint specific that should be handled in the component + * using 'loadAndDefine(componentList[blockName])' */ /** * @type {Object.} @@ -54,14 +59,34 @@ export const componentList = { tag: 'raqn-header', method: 'append', queryLevel: 1, - module: { path: '/blocks/header/header' }, - dependencies: ['navigation', 'grid', 'grid-item'], - priority: 1, + metaHeader: getMeta(metaTags.header.metaName), + filterNode(node) { + if (['header', this.tag].includes(node.tag)) { + // if the header is disabled remove it + if (!this.metaHeader) return node.remove() || false; + return true; + } + return false; + }, + module: { + path: '/blocks/header/header', + priority: 1, + dependencies: ['navigation', 'grid', 'grid-item'], + }, }, footer: { tag: 'raqn-footer', method: 'append', queryLevel: 1, + metaFooter: getMeta(metaTags.footer.metaName), + filterNode(node) { + if (['footer', this.tag].includes(node.tag)) { + // if the footer is disabled remove it + if (!this.metaFooter) return node.remove() || false; + return true; + } + return false; + }, module: { path: '/blocks/footer/footer', priority: 3, @@ -76,14 +101,6 @@ export const componentList = { transform(node) { node.tag = this.tag; - // Set options from section metadata to section. - const metaBlock = 'section-metadata'; - const [sectionMetaData] = node.queryAll((n) => n.hasClass(metaBlock)); - if (sectionMetaData) { - node.class = [...sectionMetaData.class.filter((c) => c !== metaBlock)]; - sectionMetaData.remove(); - } - // Handle sections with multiple grids const sectionGrids = node.queryAll((n) => n.hasClass('grid'), { queryLevel: 1 }); if (sectionGrids.length > 1) { @@ -92,6 +109,16 @@ export const componentList = { } else { node.remove(); } + return; + } + + // Set options from section metadata to section. + const metaBlock = 'section-metadata'; + const [sectionMetaData] = node.queryAll((n) => n.hasClass(metaBlock)); + if (sectionMetaData) { + node.class = [...sectionMetaData.class.filter((c) => c !== metaBlock)]; + setPropsAndAttributes(node); + sectionMetaData.remove(); } }, }, @@ -101,16 +128,19 @@ export const componentList = { module: { path: '/blocks/navigation/navigation', priority: 1, + dependencies: ['icon'], }, - // dependencies: ['accordion', 'icon'], }, icon: { tag: 'raqn-icon', - method: 'replace', module: { path: '/blocks/icon/icon', priority: 1, }, + transform(node) { + node.tag = this.tag; + node.attributes['data-icon'] = getClassWithPrefix(node, 'icon-'); + }, }, picture: { tag: 'raqn-image', @@ -149,6 +179,7 @@ export const componentList = { module: { path: '/blocks/accordion/accordion', priority: 2, + dependencies: ['icon'], }, }, button: { @@ -221,8 +252,8 @@ export const componentList = { module: { path: '/blocks/grid/grid', priority: 0, + dependencies: ['grid-item'], }, - dependencies: ['grid-item'], }, 'grid-item': { method: 'replace', diff --git a/scripts/editor-preview.js b/scripts/editor-preview.js index fd19ab57..a4e5edc1 100644 --- a/scripts/editor-preview.js +++ b/scripts/editor-preview.js @@ -16,7 +16,7 @@ export default async function preview(component, classes, uuid) { main.innerHTML = ''; document.body.append(main); - await main.append(...renderVirtualDom(pageManipulation(virtualDom))); + await main.append(...renderVirtualDom(await pageManipulation(virtualDom))); webComponent.style.display = 'inline-grid'; webComponent.style.width = 'auto'; diff --git a/scripts/editor.js b/scripts/editor.js index ad08d877..56482209 100644 --- a/scripts/editor.js +++ b/scripts/editor.js @@ -92,7 +92,7 @@ export default function initEditor(listeners = true) { const fn = window.raqnComponents[componentName]; const name = fn.name.toLowerCase(); const component = await loadModule(`/blocks/${name}/${name}.editor`, { loadCSS: false }); - const mod = await component.js; + const mod = component.js; if (mod && mod.default) { const dialog = await mod.default(); diff --git a/scripts/index.js b/scripts/index.js index c798b88e..72d0e981 100644 --- a/scripts/index.js +++ b/scripts/index.js @@ -18,6 +18,7 @@ export default { generatePageVirtualDom() { window.raqnVirtualDom = generateVirtualDom(document.body.childNodes); + document.body.innerHTML = ''; }, async pageVirtualDomManipulation() { @@ -28,7 +29,6 @@ export default { const renderedDOM = renderVirtualDom(window.raqnVirtualDom); if (renderedDOM) { - document.body.innerHTML = ''; document.body.append(...renderedDOM); } }, diff --git a/scripts/libs.js b/scripts/libs.js index 12f54bfa..95c7c46b 100644 --- a/scripts/libs.js +++ b/scripts/libs.js @@ -53,10 +53,6 @@ export const metaTags = { fallbackContent: '/footer', // contentType: 'path without extension', }, - structure: { - metaNamePrefix: 'structure', - // contentType: 'boolean string', - }, template: { metaName: 'template', fallbackContent: '/page-templates/', @@ -73,16 +69,17 @@ export const metaTags = { }, themeConfig: { metaNamePrefix: 'theme-config', + fallbackContent: '/configs/', // contentType: 'boolean string', }, themeConfigColor: { metaName: 'theme-config-color', - fallbackContent: '/color', + fallbackContent: '/configs/color', // contentType: 'path without extension', }, themeConfigFont: { metaName: 'theme-config-font', - fallbackContent: '/font', + fallbackContent: '/configs/font', // contentType: 'path without extension', }, themeConfigFontFiles: { @@ -92,12 +89,12 @@ export const metaTags = { }, themeConfigLayout: { metaName: 'theme-config-layout', - fallbackContent: '/layout', + fallbackContent: '/configs/layout', // contentType: 'path without extension', }, themeConfigComponent: { metaName: 'theme-config-component', - fallbackContent: '/components-config', + fallbackContent: '/configs/components-config', // contentType: 'path without extension', }, theme: { @@ -124,8 +121,9 @@ export const isPreview = () => { export const isTemplatePage = (url) => (url || window.location.pathname).includes(metaTags.template.fallbackContent); +export const capitalizeCase = (val) => val.replace(/^[a-z]/g, (k) => k.toUpperCase()); export const camelCaseAttr = (val) => val.replace(/-([a-z])/g, (k) => k[1].toUpperCase()); -export const capitalizeCaseAttr = (val) => camelCaseAttr(val.replace(/^[a-z]/g, (k) => k.toUpperCase())); +export const capitalizeCaseAttr = (val) => camelCaseAttr(capitalizeCase(val)); export function getMediaQuery(breakpointMin, breakpointMax) { const min = `(min-width: ${breakpointMin}px)`; @@ -142,6 +140,7 @@ export function getBreakPoints() { ordered: [], byName: {}, active: null, + previousActive: null, }; // return if already set @@ -182,6 +181,7 @@ export function listenBreakpointChange(callback) { active = { ...breakpoint }; if (breakpoints.active.name !== breakpoint.name) { breakpoints.active = { ...breakpoint }; + breakpoints.previousActive = { ...e.previousRaqnBreakpoint }; } } @@ -346,62 +346,139 @@ export function deepMerge(origin, ...toMerge) { return deepMerge(origin, ...toMerge); } -export function loadModule(urlWithoutExtension, { loadCSS = true, loadJS = true }) { - const modules = { js: Promise.resolve(), css: Promise.resolve() }; - if (!urlWithoutExtension) return modules; - try { - if (loadJS) { - modules.js = import(`${urlWithoutExtension}.js`); - } +/** + * Helps handle the merging of non object prop values like string or arrays + * by providing a key path and a method which defines how to handle the merge. + * @example + * keyPathMethods: { + * '**.*': (a, b) => {}, // matches any key at any level. As an example by using a,b type checks can define general merging handling or all arrays properties. + * '**.class': (a, b) => {} // matches class key at any level + * '**.class|classes': (a, b) => {} // matches class or classes keys at any level + * '**.all|xs.class': (a, b) => {} // matches all.class or xs.class key paths at any level of nesting + * 'all.class': (a, b) => {} // matches the exact key path nesting + * 'all.class': (a, b) => {} // matches the exact key path nesting + * 'all|xs.*.settings|config.*.class': (a, b) => { // matches class key at 5th level of nesting where '*' can be any + * } // key and first level is all and 3rd level si settings + * '*.*.*.class': (a, b) => {} // matches class key at 4th level of nesting where '*' can be any key + * '*.*.*.class': (a, b) => {} // matches class key at 4th level of nesting where '*' can be any key + * } + */ +export function deepMergeByType(keyPathMethods, origin, ...toMerge) { + if (!toMerge.length) return origin; + const merge = toMerge.shift(); + const pathsArrays = + keyPathMethods?.pathsArrays || + Object.entries(keyPathMethods).flatMap(([key, method]) => { + if (key === 'currentPath') return []; + return [[key.split('.').map((k) => k.split('|')), method]]; + }); + const { currentPath = [] } = keyPathMethods; - if (loadCSS) { - modules.css = new Promise((resolve, reject) => { - const cssHref = `${urlWithoutExtension}.css`; - const style = document.querySelector(`head > link[href="${cssHref}"]`); - if (!style) { - const link = document.createElement('link'); - link.href = cssHref; - // make the css loading not be render blocking - link.rel = 'preload'; - link.as = 'style'; - link.onload = () => { - link.onload = null; - link.rel = 'stylesheet'; - resolve(link); - }; - link.onerror = reject; - document.head.append(link); - } else { - resolve(style); + if (isOnlyObject(origin) && isOnlyObject(merge)) { + Object.keys(merge).forEach((key) => { + const localPath = [...currentPath, key]; + + if (isOnlyObject(merge[key])) { + const noKeyInOrigin = !origin[key]; + // overwrite origin non object values with objects + const overwriteOriginWithObject = !isOnlyObject(origin[key]) && isOnlyObject(merge[key]); + if (noKeyInOrigin || overwriteOriginWithObject) { + Object.assign(origin, { [key]: {} }); } - }).catch((error) => - // eslint-disable-next-line no-console - console.log('Could not load module style', urlWithoutExtension, error), - ); - } + deepMergeByType({ pathsArrays, currentPath: localPath }, origin[key], merge[key]); + } else { + const extendByBath = + !!pathsArrays.length && + pathsArrays.some(([keyPathPattern, method]) => { + const keyPathCheck = [...keyPathPattern]; + const localPathCheck = [...localPath]; + + if (keyPathCheck.at(0).at(0) === '**') { + keyPathCheck.shift(); + if (localPathCheck.length > keyPathCheck.length) { + localPathCheck.splice(0, localPathCheck.length - keyPathCheck.length); + } + } + + if (localPathCheck.length !== keyPathCheck.length) return false; + + const isPathMatch = localPathCheck.every((k, i) => + keyPathCheck[i].some((check) => k === check || check === '*'), + ); + if (!isPathMatch) return false; + Object.assign(origin, { [key]: method(origin[key], merge[key]) }); + return true; + }); + + if (!extendByBath) { + Object.assign(origin, { [key]: merge[key] }); + } + } + }); + } + + return deepMergeByType({ pathsArrays, currentPath }, origin, ...toMerge); +} + +export async function loadModule(urlWithoutExtension, { loadCSS = true, loadJS = true }) { + const modules = { js: Promise.resolve(null), css: Promise.resolve(null) }; + if (!urlWithoutExtension) return modules; - return modules; - } catch (error) { - // eslint-disable-next-line no-console - console.log('Could not load module', urlWithoutExtension, error); + if (loadJS) { + modules.js = import(`${urlWithoutExtension}.js`).catch((error) => { + // eslint-disable-next-line no-console + console.log('Could not load module js', urlWithoutExtension, error); + return error; + }); + } + + if (loadCSS) { + modules.css = new Promise((resolve, reject) => { + const cssHref = `${urlWithoutExtension}.css`; + const style = document.querySelector(`head > link[href="${cssHref}"]`); + if (!style) { + const link = document.createElement('link'); + link.href = cssHref; + // make the css loading not be render blocking + link.rel = 'preload'; + link.as = 'style'; + link.onload = () => { + link.onload = null; + link.rel = 'stylesheet'; + resolve(link); + }; + link.onerror = (error) => reject(error); + document.head.append(link); + } else { + style.onload = () => { + resolve(style); + }; + style.onerror = (error) => reject(error); + } + }).catch((error) => { + // eslint-disable-next-line no-console + console.log('Could not load module style', urlWithoutExtension, error); + return error; + }); } + + modules.js = await modules.js; + modules.css = await modules.css; + return modules; } export async function loadAndDefine(componentConfig) { const { tag, module: { path, loadJS, loadCSS } = {} } = componentConfig; - const { js, css } = loadModule(path, { loadJS, loadCSS }); + const { js, css } = await loadModule(path, { loadJS, loadCSS }); - const module = await js; - const style = await css; - - if (module?.default.prototype instanceof HTMLElement) { + if (js?.default?.prototype instanceof HTMLElement) { if (!window.customElements.get(tag)) { - window.customElements.define(tag, module.default); - window.raqnComponents[tag] = module.default; + window.customElements.define(tag, js.default); + window.raqnComponents[tag] = js.default; } } - return { tag, module, style }; + return { tag, module: js, style: css }; } export function mergeUniqueArrays(...arrays) { @@ -512,7 +589,7 @@ export const focusTrap = (elem, { dynamicContent } = { dynamicContent: false }) * @param {String} alreadyFlat - prefix or recursive keys. * */ -export function flat(obj = {}, alreadyFlat = '', sep = '-', maxDepth = 10) { +export function flat(obj = {}, alreadyFlat = '', sep = '.', maxDepth = 10) { const f = {}; // check if its a object Object.keys(obj).forEach((k) => { @@ -531,7 +608,7 @@ export function flat(obj = {}, alreadyFlat = '', sep = '-', maxDepth = 10) { return f; } -export function flatAsValue(data, sep = '-') { +export function flatAsValue(data, sep = '.') { return Object.entries(data) .reduce((acc, [key, value]) => { if (isObject(value)) { @@ -542,7 +619,7 @@ export function flatAsValue(data, sep = '-') { .trim(); } -export function flatAsClasses(data, sep = '-') { +export function flatAsClasses(data, sep = '.') { return Object.entries(data) .reduce((acc, [key, value]) => { const accm = acc ? `${acc} ` : ''; @@ -563,7 +640,7 @@ export function flatAsClasses(data, sep = '-') { * @param {Object} obj - Object to unflatten * */ -export function unFlat(f, sep = '-') { +export function unFlat(f, sep = '.') { const un = {}; // for each key create objects Object.keys(f).forEach((key) => { @@ -621,8 +698,12 @@ export const previewModule = async (path, name) => { }; export function yieldToMain() { - return new Promise((resolve) => { - setTimeout(resolve, 0); + return new Promise((resolve, reject) => { + try { + setTimeout(resolve, 0); + } catch (error) { + reject(); + } }); } @@ -651,8 +732,14 @@ export async function runTasks(params, ...taskList) { const task = taskList.shift(); // Run the task: - // eslint-disable-next-line no-await-in-loop - let result = await task.call(this, prevResults, i); + let result = null; + try { + // eslint-disable-next-line no-await-in-loop + result = await task.call(this, prevResults, i); + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + } if (!task.name.length) { // eslint-disable-next-line no-console console.warn("The task doesn't have a name. Please use a named function to create the task."); diff --git a/scripts/libs/external-config.js b/scripts/libs/external-config.js index ad0259e8..960a4311 100644 --- a/scripts/libs/external-config.js +++ b/scripts/libs/external-config.js @@ -4,10 +4,11 @@ window.raqnComponentsMasterConfig = window.raqnComponentsMasterConfig || null; // eslint-disable-next-line import/prefer-default-export export const externalConfig = { - async getConfig(componentName, configName = 'default') { + async getConfig(componentName, configName) { + const configNameFallback = configName || 'default'; window.raqnComponentsMasterConfig ??= await this.loadConfig(); const componentConfig = window.raqnComponentsMasterConfig?.[componentName]; - const parsedConfig = componentConfig?.[configName]; + const parsedConfig = componentConfig?.[configNameFallback]; // return copy of object to prevent mutation of raqnComponentsMasterConfig; if (parsedConfig) return deepMerge({}, parsedConfig); @@ -16,8 +17,18 @@ export const externalConfig = { async loadConfig(rawConfig) { window.raqnComponentsConfig ??= (async () => { - const { metaName } = metaTags.themeConfigComponent; + const { + themeConfigComponent: { metaName }, + themeConfig, + } = metaTags; const metaConfigPath = getMeta(metaName); + if (!metaConfigPath.includes(`${themeConfig.fallbackContent}`)) { + // eslint-disable-next-line no-console + console.error( + `The configured "${metaName}" config url is not containing a "${themeConfig.fallbackContent}" folder.`, + ); + return {}; + } const basepath = getBaseUrl(); const configPath = `${basepath}${metaConfigPath}.json`; let result = null; diff --git a/scripts/render/dom-manipulations.js b/scripts/render/dom-manipulations.js index 3c54aa67..3c7a0cd2 100644 --- a/scripts/render/dom-manipulations.js +++ b/scripts/render/dom-manipulations.js @@ -14,6 +14,7 @@ import { isPreview } from '../libs.js'; const { tplPageDuplicatedPlaceholder, highlightTemplatePlaceholders } = await forPreviewManipulation(); +// ! curryManipulation returns a promise. // preset manipulation for main page export const pageManipulation = curryManipulation([ recursive(cleanEmptyNodes), diff --git a/scripts/render/dom-reducers.js b/scripts/render/dom-reducers.js index c60630c4..9e21d7bd 100644 --- a/scripts/render/dom-reducers.js +++ b/scripts/render/dom-reducers.js @@ -1,6 +1,6 @@ // eslint-disable-next-line import/prefer-default-export import { deepMerge, getMeta, loadAndDefine, previewModule } from '../libs.js'; -import { recursive, tplPlaceholderCheck, queryTemplatePlaceholders } from './dom-utils.js'; +import { recursive, tplPlaceholderCheck, queryTemplatePlaceholders, setPropsAndAttributes } from './dom-utils.js'; import { componentList, injectedComponents } from '../component-list/component-list.js'; window.raqnLoadedComponents ??= {}; @@ -46,7 +46,7 @@ export const prepareGrid = (node) => { }; const addToLoadComponents = (blockSelector, config) => { - const { dependencies } = config; + const { dependencies } = config.module || {}; const toLoad = [blockSelector, ...(dependencies || [])]; @@ -74,28 +74,33 @@ export const toWebComponent = (virtualDom) => { // Simple and fast in place tag replacement recursive((node) => { - replaceBlocks.forEach(([blockSelector, config]) => { - if (node?.class?.includes?.(blockSelector) || config.filterNode?.(node)) { + replaceBlocks.forEach(([blockName, config]) => { + if (node?.class?.includes?.(blockName) || config.filterNode?.(node)) { node.tag = config.tag; - addToLoadComponents(blockSelector, config); + setPropsAndAttributes(node); + addToLoadComponents(blockName, config); } }); })(virtualDom); // More complex transformation need to be done in order based on a separate query for each component. - queryBlocks.forEach(([blockSelector, config]) => { + queryBlocks.forEach(([blockName, config]) => { const filter = - config.filterNode || ((node) => node?.class?.includes?.(blockSelector) || node.tag === blockSelector); + config.filterNode?.bind(config) || ((node) => node?.class?.includes?.(blockName) || node.tag === blockName); const nodes = virtualDom.queryAll(filter, { queryLevel: config.queryLevel }); nodes.forEach((node) => { const defaultNode = [{ tag: config.tag }]; const hasTransform = typeof config.transform === 'function'; const transformNode = config.transform?.(node); - if ((!hasTransform || (hasTransform && transformNode)) && config.method) { - node[config.method](...(transformNode || defaultNode)); + if ((!hasTransform || (hasTransform && transformNode?.length)) && config.method) { + const newNode = transformNode || defaultNode; + newNode[0].class ??= []; + newNode[0].class.push(...node.class); + setPropsAndAttributes(newNode[0]); + node[config.method](...newNode); } - addToLoadComponents(blockSelector, config); + addToLoadComponents(blockName, config); }); }); }; @@ -109,9 +114,14 @@ export const loadModules = (nodes, extra = {}) => { const { tag, priority } = modules[component]; if (window.raqnComponents[tag]) return window.raqnComponents[tag]; if (!modules[component]?.module?.path) return []; - return new Promise((resolve) => { + return new Promise((resolve, reject) => { setTimeout(async () => { - resolve(await loadAndDefine(modules[component]).js); + try { + const { module } = await loadAndDefine(modules[component]); + resolve(module); + } catch (error) { + reject(error); + } }, priority || 0); }); }); diff --git a/scripts/render/dom-reducers.preview.js b/scripts/render/dom-reducers.preview.js index a66866c4..b8d4117f 100644 --- a/scripts/render/dom-reducers.preview.js +++ b/scripts/render/dom-reducers.preview.js @@ -13,19 +13,11 @@ export const highlightTemplatePlaceholders = (tplVirtualDom) => { export const noContentPlaceholder = (node) => { node.class.push('template-placeholder'); - node.append( - { - tag: 'span', - class: ['error-message-box'], - children: [ - { - tag: 'textNode', - text: "This template placeholder doesn't have content in this page", - }, - ], - }, - { processChildren: true }, - ); + node.append({ + tag: 'span', + class: ['error-message-box'], + text: "This template placeholder doesn't have content in this page", + }); }; export const duplicatedPlaceholder = (placeholdersNodes, placeholders, markAll = false) => { @@ -49,19 +41,11 @@ export const duplicatedPlaceholder = (placeholdersNodes, placeholders, markAll = duplicatesNodes.forEach((node) => { node.class.push('template-placeholder'); - node.append( - { - tag: 'span', - class: ['error-message-box'], - children: [ - { - tag: 'textNode', - text: 'This template placeholder is duplicated in the template', - }, - ], - }, - { processChildren: true }, - ); + node.append({ + tag: 'span', + class: ['error-message-box'], + text: 'This template placeholder is duplicated in the template', + }); }); }; diff --git a/scripts/render/dom-utils.js b/scripts/render/dom-utils.js index ee5bfd98..2a61cdef 100644 --- a/scripts/render/dom-utils.js +++ b/scripts/render/dom-utils.js @@ -18,12 +18,17 @@ export const recursive = export const queryAllNodes = (nodes, fn, settings) => { const { currentLevel = 1, queryLevel } = settings || {}; return nodes.reduce((acc, node) => { + const hasParent = node.hasParentNode; const match = fn(node); + const wasRemoved = !node.hasParentNode && hasParent; + if (match) acc.push(node); // If this will throw an error it means the node was not created properly using the `createNodes()` method // with the `processChildren` option set to `true` if the node has children. // ! do not fix the error here by checking is node.children exists. try { + // if the node was removed during the query skip the children. + if (wasRemoved) return acc; if (node.children.length && (!queryLevel || currentLevel < queryLevel)) { const fromChildren = queryAllNodes(node.children, fn, { currentLevel: currentLevel + 1, queryLevel }); acc.push(...fromChildren); @@ -96,4 +101,15 @@ export const queryTemplatePlaceholders = (tplVirtualDom) => { return true; }); return { placeholders, placeholdersNodes }; -}; \ No newline at end of file +}; + +export const getClassWithPrefix = (node, prefix) => + node.class.find((cls, i) => cls.startsWith(prefix) && node.class.splice(i, 1))?.slice(prefix.length); + +export const setPropsAndAttributes = (node) => { + const externalConfig = getClassWithPrefix(node, 'config-'); + + if (externalConfig) { + node.attributes['config-id'] = externalConfig; + } +}; diff --git a/scripts/render/dom.js b/scripts/render/dom.js index 5c014191..539bb0a0 100644 --- a/scripts/render/dom.js +++ b/scripts/render/dom.js @@ -1,4 +1,3 @@ -import { classToFlat } from '../libs.js'; import { queryAllNodes } from './dom-utils.js'; // define instances for web components @@ -16,6 +15,19 @@ export const recursiveParent = (node) => { const getSettings = (nodes) => (nodes.length > 1 && Object.hasOwn(nodes.at(-1), 'processChildren') && nodes.pop()) || {}; +const nodeDefaults = () => ({ + isRoot: null, + tag: null, + class: [], + id: null, + parentNode: null, + siblings: [], + children: [], + customProps: {}, + attributes: {}, + text: null, +}); + // proxy object to enhance virtual dom node object. export function nodeProxy(rawNode) { const proxyNode = new Proxy(rawNode, { @@ -54,6 +66,11 @@ export function nodeProxy(rawNode) { return target.children.at(-1); } + // mehod + if (prop === 'hasParentNode') { + return target.parentNode && target.parentNode.isProxy; + } + // mehod if (prop === 'hasOnlyChild') { return (tagName) => target.children.length === 1 && target.children[0].tag === tagName; @@ -198,9 +215,7 @@ function createNodes({ nodes, siblings = [], parentNode = null, processChildren const node = (n.isProxy && n) || nodeProxy({ - class: [], - attributes: [], - children: [], + ...nodeDefaults(), ...n, }); node.siblings = siblings; @@ -230,12 +245,9 @@ export const generateVirtualDom = (realDomNodes, { reference = true, parentNode const isRoot = parentNode === 'virtualDom'; const virtualDom = isRoot ? nodeProxy({ + ...nodeDefaults(), isRoot: true, tag: parentNode, - parentNode: null, - siblings: [], - children: [], - attributes: {}, }) : { children: [], @@ -250,16 +262,18 @@ export const generateVirtualDom = (realDomNodes, { reference = true, parentNode // eslint-disable-next-line no-plusplus for (let j = 0; j < element.attributes.length; j++) { const { name, value } = element.attributes[j]; - attributes[name] = value; + if (!['id', 'class'].includes(name)) { + attributes[name] = value; + } } } const node = nodeProxy({ + ...nodeDefaults(), + tag: element.tagName ? element.tagName.toLowerCase() : 'textNode', parentNode: isRoot ? virtualDom : parentNode, siblings: virtualDom.children, - tag: element.tagName ? element.tagName.toLowerCase() : 'textNode', class: classList, - attributesValues: classToFlat(classList), id: element.id, attributes, text: !element.tagName ? element.textContent : null, @@ -280,44 +294,36 @@ export const renderVirtualDom = (virtualdom) => { // eslint-disable-next-line no-plusplus for (let i = 0; i < siblings.length; i++) { const virtualNode = siblings[i]; - const { children } = virtualNode; - const child = children ? renderVirtualDom(children) : null; + const children = virtualNode.children ? renderVirtualDom(virtualNode.children) : null; if (virtualNode.tag !== 'textNode') { const el = document.createElement(virtualNode.tag); if (virtualNode.tag.indexOf('raqn-') === 0) { el.setAttribute('raqnwebcomponent', ''); - if (!window.raqnInstances[virtualNode.tag]) { - window.raqnInstances[virtualNode.tag] = []; - } + window.raqnInstances[virtualNode.tag] ??= []; window.raqnInstances[virtualNode.tag].push(el); } - if (virtualNode.class?.length > 0) { - el.classList.add(...virtualNode.class); - } - if (virtualNode.id) { - el.id = virtualNode.id; - } - if (virtualNode.attributes) { - Object.keys(virtualNode.attributes).forEach((name) => { - const value = virtualNode.attributes[name]; - el.setAttribute(name, value); - }); - } - - virtualNode.initialAttributesValues = classToFlat(virtualNode.class); - if (virtualNode.text) { - el.textContent = virtualNode.text; - } + if (virtualNode.class?.length > 0) el.classList.add(...virtualNode.class); + if (virtualNode.id) el.id = virtualNode.id; + if (virtualNode.text?.length) el.textContent = virtualNode.text; + if (children) el.append(...children); + + Object.entries(virtualNode.attributes).forEach(([name, value]) => { + el.setAttribute(name, value); + }); + + Object.entries(virtualNode.customProps).forEach(([name, value]) => { + el[name] = value; + }); - if (child) { - el.append(...child); - } virtualNode.reference = el; + el.virtualNode = virtualNode; + dom.push(el); - } else { + } else if (virtualNode.text?.length) { const textNode = document.createTextNode(virtualNode.text); virtualNode.reference = textNode; + textNode.virtualNode = virtualNode; dom.push(textNode); } } diff --git a/styles/styles.css b/styles/styles.css index 0e795902..c5bc6c21 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -201,7 +201,7 @@ img { /* Set default block style to all raqn web components Use :where() to give lower specificity in order to not overwrite any display option set on the web component tag */ -:where([raqnwebcomponent]) { +:where([raqnwebcomponent]) { display: block; } @@ -215,27 +215,12 @@ main > div > *:not(.full-width) { width: initial; } - /* Change the above behavior by setting the full-with class on the block. This will make the background take the full width of the page */ .full-width { max-width: var(--max-width); margin-inline: auto; } -/* TODO Check if this is still needed */ -main > div > div { - background: var(--background, #fff); - color: var(--text, #000); - padding: var(--padding, 0); -} - -/* TODO Check if this is still needed */ -main > div > div > div { - max-width: var(--max-width, 100%); - margin: var(--margin, 0 auto); - width: 100%; -} - a { align-items: center; color: var(--highlight, inherit); @@ -266,7 +251,7 @@ button { } /* Hide raqn web components based on different states */ -[isloading], +[isloading]:not(.show-loader), .hide-with-error, .hide { display: none; @@ -276,3 +261,43 @@ button { #franklin-svg-sprite { display: none; } + +.show-loader { + position: relative; + min-height: 100px; + + &::before { + content: ''; + position: absolute; + z-index: 1; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: white; + } + + &::after { + content: ''; + position: absolute; + left: 50%; + transform-origin: center; + z-index: 2; + border: 5px solid #f3f3f3; /* Light grey */ + border-top: 5px solid #555; /* Blue */ + border-radius: 50%; + width: 25px; + height: 25px; + animation: spin 0.75s linear infinite; + } +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +}