diff --git a/blocks/accordion/accordion.js b/blocks/accordion/accordion.js index ac3bf1da..7d5efaee 100644 --- a/blocks/accordion/accordion.js +++ b/blocks/accordion/accordion.js @@ -3,6 +3,16 @@ import ComponentBase from '../../scripts/component-base.js'; export default class Accordion extends ComponentBase { dependencies = ['icon']; + extendConfig() { + return [ + ...super.extendConfig(), + { + innerComponents: null, + nestedComponentsPrefix: ':scope > ', + }, + ]; + } + extendNestedConfig() { return [ ...super.extendNestedConfig(), diff --git a/blocks/breadcrumbs/breadcrumbs.js b/blocks/breadcrumbs/breadcrumbs.js index 67a12886..c946a486 100644 --- a/blocks/breadcrumbs/breadcrumbs.js +++ b/blocks/breadcrumbs/breadcrumbs.js @@ -8,6 +8,12 @@ export default class Breadcrumbs extends ComponentBase { targetsSelectorsLimit: 1, }; + attributesValues = { + all: { + class: 'full-width', + }, + }; + nestedComponentsConfig = {}; extendConfig() { @@ -24,9 +30,7 @@ export default class Breadcrumbs extends ComponentBase { ]; } - connected() { - this.classList.add('full-width'); - this.classList.add('breadcrumbs'); + addHtml() { const { origin, pathname } = window.location; let breadcrumbRoot = getMeta(metaTags.breadcrumbRoot.metaName); breadcrumbRoot = breadcrumbRoot?.startsWith('/') ? breadcrumbRoot : `/${breadcrumbRoot}`; diff --git a/blocks/button/button.js b/blocks/button/button.js index cb3d1a5d..6f3d07ef 100644 --- a/blocks/button/button.js +++ b/blocks/button/button.js @@ -28,7 +28,7 @@ export default class Button extends ComponentBase { ]; } - connected() { + addEDSHtml() { this.initAsBlock(); this.queryElements(); this.wrapText(); diff --git a/blocks/footer/footer.css b/blocks/footer/footer.css index 9be87f1b..14d0c78d 100644 --- a/blocks/footer/footer.css +++ b/blocks/footer/footer.css @@ -1,12 +1,8 @@ -footer { - background: var(--background-color); - width: var(--max-width); - margin: 0 auto; -} - raqn-footer { background: var(--background-color); border-top: 1px solid var(--text); + padding: 20px 0; + } raqn-footer ul { @@ -22,8 +18,8 @@ raqn-footer ul li a { @media screen and (min-width: 1024px) { raqn-footer { - display: grid; - grid-template-columns: auto 20vw; + /* display: grid; + grid-template-columns: auto 20vw; */ } raqn-footer ul li a { @@ -40,10 +36,10 @@ raqn-footer ul li a { margin: 2em 0; } - raqn-footer > *:last-child { + /* raqn-footer > *:last-child { justify-self: end; align-self: center; - } + } */ } raqn-footer ul li:last-child a { diff --git a/blocks/footer/footer.js b/blocks/footer/footer.js index e7f78b56..a3b53776 100644 --- a/blocks/footer/footer.js +++ b/blocks/footer/footer.js @@ -6,6 +6,8 @@ const metaFooter = getMeta(metaTags.footer.metaName); export default class Footer extends ComponentBase { static loaderConfig = { ...ComponentBase.loaderConfig, + targetsSelectors: ':scope > footer', + targetsSelectorsLimit: 1, loaderStopInit() { return metaFooter === false; }, @@ -17,18 +19,18 @@ export default class Footer extends ComponentBase { return [ ...super.extendConfig(), { + contentFromTargets: false, addToTargetMethod: 'append', + targetsAsContainers: { + addToTargetMethod: 'append', + contentFromTargets: false, + }, }, ]; } 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'); } } diff --git a/blocks/grid-item/grid-item.js b/blocks/grid-item/grid-item.js index 312a2398..68bf62b0 100644 --- a/blocks/grid-item/grid-item.js +++ b/blocks/grid-item/grid-item.js @@ -1,4 +1,5 @@ import ComponentBase from '../../scripts/component-base.js'; +import { globalConfig } from '../../scripts/libs.js'; export default class Grid extends ComponentBase { static observedAttributes = [ @@ -12,7 +13,7 @@ export default class Grid extends ComponentBase { 'data-align', ]; - nestedComponentsConfig = {}; + // nestedComponentsConfig = {}; attributesValues = { all: { @@ -22,8 +23,18 @@ export default class Grid extends ComponentBase { }, }; + extendConfig() { + return [ + ...super.extendConfig(), + { + innerComponents: globalConfig.blockSelector, + }, + ]; + } + setDefaults() { super.setDefaults(); + this.gridParent = null; } @@ -48,7 +59,7 @@ export default class Grid extends ComponentBase { } } - connected() { + ready() { this.gridParent ??= this.parentElement; } @@ -87,4 +98,28 @@ export default class Grid extends ComponentBase { this.style.removeProperty(prop); } } + + addEDSHtml() { + if (!this.isInitAsBlock) return; + + this.recursiveItems(this.previousElementSibling); + } + + recursiveItems(elem) { + if (!elem) return; + if (this.isGridItem(elem)) return; + if (this.isRaqnGrid(elem)) return; + + this.prepend(elem); + + this.recursiveItems(this.previousElementSibling); + } + + isGridItem(elem) { + return elem.tagName === 'DIV' && elem.classList.contains('grid-item'); + } + + isRaqnGrid(elem) { + return elem.tagName === 'RAQN-GRID-ITEM'; + } } diff --git a/blocks/grid/grid.css b/blocks/grid/grid.css index ba44b35b..5a535ba9 100644 --- a/blocks/grid/grid.css +++ b/blocks/grid/grid.css @@ -1,7 +1,7 @@ raqn-grid { /* Set to initial to prevent inheritance for nested grids */ --grid-height: initial; - --grid-width: 100%; + --grid-width: initial; --grid-justify-items: initial; --grid-align-items: initial; --grid-justify-content: initial; diff --git a/blocks/grid/grid.js b/blocks/grid/grid.js index b46a68d5..a75b8094 100644 --- a/blocks/grid/grid.js +++ b/blocks/grid/grid.js @@ -1,6 +1,5 @@ import ComponentBase from '../../scripts/component-base.js'; import { stringToJsVal } from '../../scripts/libs.js'; -import component from '../../scripts/init.js'; export default class Grid extends ComponentBase { static observedAttributes = [ @@ -29,12 +28,17 @@ export default class Grid extends ComponentBase { }, }; - setDefaults() { - super.setDefaults(); + extendConfig() { + return [ + ...super.extendConfig(), + { + innerComponents: ':scope > .grid-item', + }, + ]; } get gridItems() { - return [...this.children]; + return [...this.children].filter((el) => el.tagName.toLowerCase() === 'raqn-grid-item'); } onAttributeHeightChanged({ oldValue, newValue }) { @@ -190,87 +194,20 @@ export default class Grid extends ComponentBase { } } - async connected() { - await this.collectGridItemsFromBlocks(); - } - - ready() { - this.cleanGridItems(); - } - - cleanGridItems() { - // Get all the grid items and remove any non grid item element. - return [...this.children].filter((child) => child.matches('raqn-grid-item') || child.remove()); - } - - async collectGridItemsFromBlocks() { + async addEDSHtml() { if (!this.isInitAsBlock) return; - await this.recursiveItems(this.nextElementSibling); - } + const elems = [...this.parentElement.children]; - async recursiveItems(elem, children = []) { - if (!elem) return; - if (this.isForbiddenGridItem(elem)) return; - if (this.isForbiddenBlockGrid(elem)) return; - if (this.isForbiddenRaqnGrid(elem)) return; + const gridIndex = elems.indexOf(this); - if (this.isThisGridItem(elem)) { - await this.createGridItem([...children], [...elem.classList]); - await this.recursiveItems(elem.nextElementSibling, []); - elem.remove(); - return; - } + let children = elems.slice(gridIndex + 1); - children.push(elem); - - await this.recursiveItems(elem.nextElementSibling, children); - } + const lastItem = [...children].reverse().find((el) => el.matches('.grid-item')); + const lastItemIndex = children.indexOf(lastItem); - getLevel(elem = this) { - return Number(elem.dataset.level); - } - - getLevelFromClass(elem) { - const levelClass = [...elem.classList].find((cls) => cls.startsWith('data-level-')) || 'data-level-1'; - return Number(levelClass.slice('data-level-'.length)); - } - - isGridItem(elem) { - return elem.tagName === 'DIV' && elem.classList.contains('grid-item'); - } - - isThisGridItem(elem) { - return this.isGridItem(elem) && this.getLevelFromClass(elem) === this.getLevel(); - } - - isForbiddenGridItem(elem) { - return this.isGridItem(elem) && this.getLevelFromClass(elem) > this.getLevel(); - } - - isBlockGrid(elem) { - return elem.tagName === 'DIV' && elem.classList.contains('grid'); - } - - isRaqnGrid(elem) { - return elem.tagName === 'RAQN-GRID'; - } - - isForbiddenRaqnGrid(elem) { - return this.isRaqnGrid(elem) && this.getLevel() >= this.getLevel(elem); - } - - isForbiddenBlockGrid(elem) { - return this.isBlockGrid(elem) && this.getLevelFromClass(elem) <= this.getLevel(); - } + children = children.slice(0, lastItemIndex + 1); - async createGridItem(children, configByClasses) { - await component.loadAndDefine('grid-item'); - const tempGridItem = document.createElement('raqn-grid-item'); - tempGridItem.init({ configByClasses }); - tempGridItem.gridParent = this; - tempGridItem.append(...children); - this.gridItems.push(tempGridItem); - this.append(tempGridItem); + this.append(...children); } } diff --git a/blocks/header/header.js b/blocks/header/header.js index 335f228c..1aaa30fa 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -6,6 +6,8 @@ const metaHeader = getMeta(metaTags.header.metaName); export default class Header extends ComponentBase { static loaderConfig = { ...ComponentBase.loaderConfig, + targetsSelectors: ':scope > header', + targetsSelectorsLimit: 1, loaderStopInit() { return metaHeader === false; }, @@ -13,9 +15,7 @@ export default class Header extends ComponentBase { attributesValues = { all: { - class: { - color: 'primary', - }, + class: 'color-primary', }, }; @@ -27,12 +27,17 @@ export default class Header extends ComponentBase { return [ ...super.extendConfig(), { + contentFromTargets: false, addToTargetMethod: 'append', + targetsAsContainers: { + addToTargetMethod: 'append', + contentFromTargets: false, + }, }, ]; } - connected() { + ready() { eagerImage(this, 1); } } diff --git a/blocks/icon/icon.js b/blocks/icon/icon.js index 6ad76e7e..cbf360b7 100644 --- a/blocks/icon/icon.js +++ b/blocks/icon/icon.js @@ -6,6 +6,14 @@ const metaIcons = getMeta(metaTags.icons.metaName); export default class Icon extends ComponentBase { static observedAttributes = ['data-active', 'data-icon']; + attributesValues = { + all: { + attribute: { + 'aria-hidden': 'true', + }, + }, + }; + #initialIcon = null; #activeIcon = null; @@ -52,10 +60,6 @@ export default class Icon extends ComponentBase { 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; diff --git a/blocks/image/image.js b/blocks/image/image.js index 74aa774a..21b89d6c 100644 --- a/blocks/image/image.js +++ b/blocks/image/image.js @@ -23,7 +23,7 @@ export default class Image extends ComponentBase { ]; } - connected() { + addHtml() { this.createLinkedImage(); } @@ -31,8 +31,9 @@ export default class Image extends ComponentBase { if (!this.children) return; const em = this.firstElementChild; const anchor = em.firstElementChild; - const pictureParent = this.parentElement.previousElementSibling; - const picture = pictureParent.firstElementChild; + const pictureParent = this.parentElement?.previousElementSibling; + const picture = pictureParent?.firstElementChild; + if (!picture) return; anchor.setAttribute('aria-label', anchor.textContent); anchor.innerHTML = ''; anchor.append(picture); diff --git a/blocks/popup/popup.js b/blocks/popup/popup.js index df7515e7..7162a423 100644 --- a/blocks/popup/popup.js +++ b/blocks/popup/popup.js @@ -1,6 +1,5 @@ import ComponentBase from '../../scripts/component-base.js'; import { - globalConfig, stringToJsVal, popupState, focusTrap, @@ -28,6 +27,7 @@ export default class Popup extends ComponentBase { return [ ...super.extendConfig(), { + innerComponents: '.popup__content > div', contentFromTargets: false, targetsAsContainers: { contentFromTargets: false, @@ -120,7 +120,7 @@ export default class Popup extends ComponentBase { }); } - connected() { + ready() { this.activeOnConnect(); } @@ -139,11 +139,6 @@ export default class Popup extends ComponentBase { this.elements.popupContent.innerHTML = await this.fragmentContent; } - setInnerBlocks() { - const innerBlocks = [...this.elements.popupContent.querySelectorAll(globalConfig.blockSelector)]; - this.innerBlocks = innerBlocks; - } - onAttributeUrlChanged({ oldValue, newValue }) { if (newValue === oldValue) return; this.fragmentPath = `${newValue}.plain.html`; diff --git a/blocks/section/section.css b/blocks/section/section.css new file mode 100644 index 00000000..354a0567 --- /dev/null +++ b/blocks/section/section.css @@ -0,0 +1 @@ +/* NOP */ \ No newline at end of file diff --git a/blocks/section/section.js b/blocks/section/section.js new file mode 100644 index 00000000..8acdb2ab --- /dev/null +++ b/blocks/section/section.js @@ -0,0 +1,53 @@ +import component from '../../scripts/init.js'; +import ComponentBase from '../../scripts/component-base.js'; +import { globalConfig } from '../../scripts/libs.js'; + +export default class Section extends ComponentBase { + static loaderConfig = { + ...ComponentBase.loaderConfig, + targetsSelectors: ':scope > div', + }; + + extendConfig() { + return [ + ...super.extendConfig(), + { + innerComponents: `:scope > ${globalConfig.blockSelector}`, + nestedComponentsPrefix: ':scope > ', + }, + ]; + } + + setInnerBlocks() { + if (!this.config.innerComponents) return; + + const elems = [...this.querySelectorAll(this.config.innerComponents)]; + const grid = elems.find((el) => el.matches('.grid')); + const gridIndex = elems.indexOf(grid); + const lastItem = [...elems].reverse().find((el) => el.matches('.grid-item')); + const lastItemIndex = elems.indexOf(lastItem); + const preGridChildren = [...elems].slice(0, gridIndex + 1); + const postGridChildren = [...elems].slice(lastItemIndex + 1); + + this.innerBlocks = [...preGridChildren, ...postGridChildren].map((elem) => component.getBlockData(elem)); + this.innerGrids = []; + } + + addEDSHtml() { + const grids = this.querySelectorAll('.grid'); + if (grids.length > 1) { + if (window.raqnIsPreview) { + this.innerHTML = '

The content of this section is hidden because it contains more than 1 grid which is not supported. Please fix.

'; + this.attributesValues.all ??= {}; + this.attributesValues.all.class ??= ''; + + this.attributesValues.all.class += ' error-message'; + this.classList.add('error-message-box'); + + // prevent hiding the component based on the throw error bellow + this.config.hideOnInitError = false; + } + throw new Error('More then 1 grid configured in this section. Please fix.'); + } + } +} diff --git a/blocks/sidekick-tools-palette/sidekick-tools-palette.js b/blocks/sidekick-tools-palette/sidekick-tools-palette.js index 783cb47a..ab51b6d4 100644 --- a/blocks/sidekick-tools-palette/sidekick-tools-palette.js +++ b/blocks/sidekick-tools-palette/sidekick-tools-palette.js @@ -376,7 +376,7 @@ export default class SidekickToolsPalette extends ComponentBase { }); } - connected() { + addHtml() { this.initPalette(); } } diff --git a/blocks/theming/theming.js b/blocks/theming/theming.js index 6acd93d0..9928e0e0 100644 --- a/blocks/theming/theming.js +++ b/blocks/theming/theming.js @@ -13,12 +13,33 @@ import { const k = Object.keys; export default class Theming extends ComponentBase { + static loaderConfig = { + ...ComponentBase.loaderConfig, + // add the component to the head. Target should be set as `document` + targetsSelectors: ':scope > head', + targetsSelectorsLimit: 1, + }; + componentsConfig = {}; elements = {}; variations = {}; + extendConfig() { + return [ + ...super.extendConfig(), + { + contentFromTargets: false, + addToTargetMethod: 'append', + targetsAsContainers: { + addToTargetMethod: 'append', + contentFromTargets: false, + }, + }, + ]; + } + setDefaults() { super.setDefaults(); this.scapeDiv = document.createElement('div'); diff --git a/head.html b/head.html index a0c21054..cd6c22eb 100644 --- a/head.html +++ b/head.html @@ -19,4 +19,3 @@ - diff --git a/scripts/component-base.js b/scripts/component-base.js index a3b749dd..b6396ed8 100644 --- a/scripts/component-base.js +++ b/scripts/component-base.js @@ -1,21 +1,22 @@ import component from './init.js'; import { - globalConfig, getBreakPoints, listenBreakpointChange, camelCaseAttr, capitalizeCaseAttr, deepMerge, + deepMergeMethod, classToFlat, unFlat, isObject, flatAsValue, flat, mergeUniqueArrays, - getBlocksAndGrids, } from './libs.js'; import { externalConfig } from './libs/external-config.js'; +window.raqnInstances ??= {}; + export default class ComponentBase extends HTMLElement { // All supported data attributes must be added to observedAttributes // The order of observedAttributes is the order in which the values from config are added. @@ -33,7 +34,7 @@ export default class ComponentBase extends HTMLElement { }; get Handler() { - return window.raqnComponents[this.componentName]; + return window.raqnComponentsHandlers[this.componentName]; } get isInitAsBlock() { @@ -43,6 +44,7 @@ export default class ComponentBase extends HTMLElement { constructor() { super(); this.setDefaults(); + this.setInstance(); this.setInitializationPromise(); this.extendConfigRunner({ config: 'config', method: 'extendConfig' }); this.extendConfigRunner({ config: 'nestedComponentsConfig', method: 'extendNestedConfig' }); @@ -53,7 +55,9 @@ export default class ComponentBase extends HTMLElement { this.uuid = `gen${crypto.randomUUID().split('-')[0]}`; this.webComponentName = this.tagName.toLowerCase(); this.componentName = this.webComponentName.replace(/^raqn-/, ''); + this.externalConfig = null; this.overrideExternalConfig = false; + this.category = null; this.wasInitBeforeConnected = false; this.fragmentPath = null; this.fragmentCache = 'default'; @@ -70,9 +74,8 @@ export default class ComponentBase extends HTMLElement { // from inner html blocks innerGrids: [], }; - // set only if content is loaded externally + this.initializeInnerBlocks = true; this.innerBlocks = []; - // set only if content is loaded externally this.innerGrids = []; this.initError = null; this.breakpoints = getBreakPoints(); @@ -80,7 +83,8 @@ export default class ComponentBase extends HTMLElement { // use the this.extendConfig() method to extend the default config this.config = { - listenBreakpoints: false, + innerComponents: undefined, + nestedComponentsPrefix: ':scope > ', hideOnInitError: true, hideOnChildrenError: false, addToTargetMethod: 'replaceWith', @@ -100,6 +104,19 @@ export default class ComponentBase extends HTMLElement { componentName: 'button', }, }; + + this.attrMerge = { + '**.class': (a, b) => { + const haveLength = [a, b].every((c) => c?.length); + + if (b && typeof a === 'string' && typeof b !== 'string') { + console.error('Merge for css classes in attributeValues failed. Values are not strings'); + return a; + } + + return haveLength ? `${a} ${b}` : b; + }, + }; } setInitializationPromise() { @@ -114,6 +131,11 @@ export default class ComponentBase extends HTMLElement { // const { promise, resolve, reject } = Promise.withResolvers(); } + setInstance() { + window.raqnInstances[this.componentName] ??= []; + window.raqnInstances[this.componentName].push(this); + } + // 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 }) { @@ -176,13 +198,16 @@ export default class ComponentBase extends HTMLElement { * use the data attr values as default for attributesValues */ setInitialAttributesValues() { - const initialAttributesValues = { all: { data: {} } }; + const initialAttributesValues = {}; + + this.externalConfigName = this.getAttribute('config-name') || this.initOptions.externalConfigName; this.dataAttributesKeys.forEach(({ noData, noDataCamelCase }) => { const value = this.dataset[noDataCamelCase]; if (typeof value === 'undefined') return {}; const initialValue = unFlat({ [noData]: value }); + initialAttributesValues.all ??= { data: {} }; initialAttributesValues.all.data = deepMerge({}, initialAttributesValues.all.data, initialValue); return initialAttributesValues; }); @@ -209,7 +234,7 @@ export default class ComponentBase extends HTMLElement { // Build-in method called after the element is added to the DOM. async connectedCallback() { // Common identifier for raqn web components - this.setAttribute('raqnWebComponent', ''); + this.setAttribute('raqnwebcomponent', ''); this.setAttribute('isloading', ''); try { this.initialized = this.getAttribute('initialized'); @@ -219,8 +244,14 @@ export default class ComponentBase extends HTMLElement { this.setAttribute('id', this.uuid); this.loadDependencies(); // do not wait for dependencies; await this.loadFragment(this.fragmentPath); - await this.connected(); // manipulate/create the html + // Add, create and manipulate only html containing EDS blocks/markup + // ! any element with a class will considered a block and transformed to a webComponent + await this.addEDSHtml(); + this.setInnerBlocks(); await this.initChildComponents(); + // Add, create and manipulate html after inner webComponents were initialized. + // Here normal html or webComponent can be created and added to the component. + await this.addHtml(); this.addListeners(); // html is ready add listeners await this.ready(); // add extra functionality this.setAttribute('initialized', true); @@ -254,22 +285,51 @@ export default class ComponentBase extends HTMLElement { } async buildExternalConfig() { - let configByClasses = mergeUniqueArrays(this.initOptions.configByClasses, this.classList); + let configByClasses = mergeUniqueArrays( + this.initOptions.configByClasses?.filter((c, index) => c.includes('-') && index !== 0), + [...this.classList].map((c) => `all-class-${c}`), + ); + // normalize the configByClasses to serializable format - const { byName } = getBreakPoints(); + const { byName } = this.breakpoints; configByClasses = configByClasses // remove the first class which is the component name and keep only compound classes - .filter((c, index) => c.includes('-') && index !== 0) + // .filter((c, index) => c.includes('-') && index !== 0) // make sure break points are included in the config .map((c) => { - const exceptions = ['all', 'config']; + const breakpoints = ['all', 'config', ...Object.keys(byName)]; const firstClass = c.split('-')[0]; - const isBreakpoint = Object.keys(byName).includes(firstClass) || exceptions.includes(firstClass); + const isBreakpoint = breakpoints.includes(firstClass); return isBreakpoint ? c : `all-${c}`; }); + const classesAndAttr = configByClasses.reduce( + (acc, c) => { + const breakpoints = ['all', ...Object.keys(byName)]; + const classBreakpoint = breakpoints.find((b) => c.startsWith(`${b}-class-`)); + + if (c.startsWith('config-')) { + // allows to have external config names with hyphens. + acc.externalConfigName = c.slice('config-'.length); + } + + if (classBreakpoint) { + acc.classes[classBreakpoint] ??= {}; + const currentClasses = acc.classes[classBreakpoint].class; + const cls = c.slice(`${classBreakpoint}-class-`.length); + acc.classes[classBreakpoint].class = currentClasses?.length ? `${currentClasses} ${cls}` : cls; + } else { + acc.attrs.push(c); + } + return acc; + }, + { classes: {}, attrs: [], externalConfigName: null }, + ); + // serialize the configByClasses into a flat object - let values = classToFlat(configByClasses); + let values = deepMerge({}, classesAndAttr.classes, classToFlat(classesAndAttr.attrs)); + + this.externalConfigName ??= classesAndAttr.externalConfigName; // get the external config // TODO With the unFlatten approach of setting this.attributesValues there is an increased amount of processing @@ -279,7 +339,7 @@ export default class ComponentBase extends HTMLElement { // - 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)); + const configs = unFlat(await externalConfig.getConfig(this.componentName, this.externalConfigName, this.category)); if (this.overrideExternalConfig) { // Used for preview functionality @@ -288,8 +348,6 @@ export default class ComponentBase extends HTMLElement { values = deepMerge({}, configs, values); } - delete values.config; - // add to attributesValues this.attributesValues = deepMerge({}, this.attributesValues, values); } @@ -335,8 +393,9 @@ export default class ComponentBase extends HTMLElement { } runConfigsByViewport() { - const { name } = getBreakPoints().active; - const current = deepMerge({}, this.attributesValues.all, this.attributesValues[name]); + const { name } = this.breakpoints.active; + const current = deepMergeMethod(this.attrMerge, {}, this.attributesValues.all, this.attributesValues[name]); + this.removeAttribute('class'); Object.keys(current).forEach((key) => { const action = `apply${key.charAt(0).toUpperCase() + key.slice(1)}`; @@ -375,7 +434,7 @@ export default class ComponentBase extends HTMLElement { this.classList.add(...flatAsValue(className).split(' ')); } else if (className) { // strings are added as is - this.setAttribute('class', className); + this.classList.add(...className.split(' ')); } } @@ -391,18 +450,10 @@ export default class ComponentBase extends HTMLElement { }); } - // ${viewport}-nest-${value} - - 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); - }); + applySetting(config) { + // delete the setting to run only once on init + delete this.attributesValues.all.setting; + deepMerge(this.config, config); } /** @@ -426,9 +477,9 @@ export default class ComponentBase extends HTMLElement { getBreakpointAttrVal(attr) { const { name: activeBrName } = this.breakpoints.active; - const attribute = this.attributesValues?.[attr]; - if (!attribute) return undefined; - return attribute?.[activeBrName] ?? attribute?.all; + const attributeAll = this.attributesValues?.all?.[attr]; + const attributeBreakpoint = this.attributesValues?.[activeBrName]?.[attr]; + return attributeBreakpoint ?? attributeAll; } addListeners() { @@ -439,7 +490,7 @@ export default class ComponentBase extends HTMLElement { async initChildComponents() { await Promise.allSettled([this.initNestedComponents(), this.initInnerBlocks()]); - await this.initInnerGrids(); + // await this.initInnerGrids(); } async initNestedComponents() { @@ -450,7 +501,7 @@ export default class ComponentBase extends HTMLElement { ? deepMerge({}, setting, { // Exclude nested components query from innerBlocks. Inner Components will query their own nested components. loaderConfig: { - targetsSelectorsPrefix: ':scope > div >', // Limit only to default content, exclude blocks. + targetsSelectorsPrefix: this.config.nestedComponentsPrefix, // Limit only to default content, exclude blocks. }, }) : setting; @@ -502,7 +553,9 @@ export default class ComponentBase extends HTMLElement { if (response.ok) { this.fragmentContent = response.text(); await this.addFragmentContent(); - this.setInnerBlocksAndGrids(); + // When html is loaded externally it will contain sections and blocks + // Initialize inner components + this.config.innerComponents ??= ':scope > div'; } } @@ -510,13 +563,15 @@ export default class ComponentBase extends HTMLElement { this.innerHTML = await this.fragmentContent; } - // Set only if content is loaded externally; - setInnerBlocksAndGrids() { - const { blocks, grids } = getBlocksAndGrids( - [...this.querySelectorAll(globalConfig.blockSelector)].map((elem) => component.getBlockData(elem)), + setInnerBlocks() { + if (!this.config.innerComponents) return; + // const { blocks, grids } = + // ; + + this.innerBlocks = [...this.querySelectorAll(this.config.innerComponents)].map((elem) => + component.getBlockData(elem), ); - this.innerBlocks = blocks; - this.innerGrids = grids; + // this.innerGrids = grids; } queryElements() { @@ -543,11 +598,17 @@ export default class ComponentBase extends HTMLElement { initSubscriptions() {} + removeSubscriptions() {} + onInit() {} - connected() {} + addEDSHtml() {} + + addHtml() {} ready() {} - disconnectedCallback() {} + disconnectedCallback() { + this.removeSubscriptions(); + } } diff --git a/scripts/component-loader.js b/scripts/component-loader.js index 3d935868..f7bf7395 100644 --- a/scripts/component-loader.js +++ b/scripts/component-loader.js @@ -1,7 +1,5 @@ import { loadModule, deepMerge, mergeUniqueArrays, getBreakPoints } from './libs.js'; -window.raqnInstances = window.raqnInstances || {}; - export default class ComponentLoader { constructor({ componentName, @@ -14,12 +12,12 @@ export default class ComponentLoader { props, nestedComponentsConfig, active, + path, }) { - window.raqnComponents ??= {}; + window.raqnComponentsHandlers ??= {}; if (!componentName) { throw new Error('`componentName` is required'); } - this.instances = window.raqnInstances || {}; this.componentName = componentName; this.targets = targets.map((target) => ({ target })); this.loaderConfig = loaderConfig; @@ -29,7 +27,8 @@ export default class ComponentLoader { this.breakpoints = getBreakPoints(); this.componentConfig = componentConfig; this.nestedComponentsConfig = nestedComponentsConfig; - this.pathWithoutExtension = `/blocks/${this.componentName}/${this.componentName}`; + this.path = path || '/blocks'; + this.pathWithoutExtension = `${this.path}/${this.componentName}/${this.componentName}`; this.props = props ?? {}; this.isWebComponent = null; this.isClass = null; @@ -38,11 +37,11 @@ export default class ComponentLoader { } get Handler() { - return window.raqnComponents[this.componentName]; + return window.raqnComponentsHandlers[this.componentName]; } set Handler(handler) { - window.raqnComponents[this.componentName] = handler; + window.raqnComponentsHandlers[this.componentName] = handler; } setHandlerType(handler = this.Handler) { @@ -90,9 +89,6 @@ export default class ComponentLoader { let elem = null; try { elem = await this.createElementAndConfigure(data); - elem.webComponentName = this.webComponentName; - this.instances[elem.componentName] = this.instances[elem.componentName] || []; - this.instances[elem.componentName].push(elem); } catch (error) { error.elem ??= elem; elem?.classList.add('hide-with-error'); diff --git a/scripts/init.js b/scripts/init.js index fd905986..797d8846 100644 --- a/scripts/init.js +++ b/scripts/init.js @@ -4,9 +4,6 @@ import { metaTags, eagerImage, getMeta, - getMetaGroup, - mergeUniqueArrays, - getBlocksAndGrids, } from './libs.js'; const component = { @@ -90,127 +87,29 @@ const component = { const lcp = block.classList.contains('lcp'); let componentName = tagName; if (!globalConfig.semanticBlocks.includes(tagName)) { - componentName = block.classList.item(0); + componentName = block.classList.item(0) || 'section'; } return { targets: [block], componentName, lcp }; }, }; export const onLoadComponents = { - // default content - staticStructureComponents: [ - { - componentName: 'image', - targets: [document], - loaderConfig: { - targetsAsContainers: true, - targetsSelectorsPrefix: 'main > div >', - }, - }, - { - componentName: 'button', - targets: [document], - loaderConfig: { - targetsAsContainers: true, - targetsSelectorsPrefix: 'main > div >', - }, - }, - ], - async init() { - this.setLcp(); - this.setStructure(); - this.queryAllBlocks(); - this.setBlocksData(); - this.setLcpBlocks(); - this.setLazyBlocks(); - this.initBlocks(); - }, - - queryAllBlocks() { - this.blocks = [ - document.body.querySelector(globalConfig.semanticBlocks[0]), - ...document.querySelectorAll(globalConfig.blockSelector), - ...document.body.querySelectorAll(globalConfig.semanticBlocks.slice(1).join(',')), - ]; - }, - - setBlocksData() { - const structureData = this.structureComponents.map(({ componentName }) => ({ - componentName, - targets: [document], - loaderConfig: { - targetsAsContainers: true, - }, - })); - structureData.push(...this.staticStructureComponents); - - const blocksData = this.blocks.map((block) => component.getBlockData(block)); - this.blocksData = [...structureData, ...blocksData]; - }, - - setLcp() { - const { metaName, fallbackContent } = metaTags.lcp; - const lcpMeta = getMeta(metaName, { getArray: true }); - const defaultLcp = fallbackContent; - const lcp = lcpMeta?.length ? lcpMeta : defaultLcp; - // theming must be in LCP to prevent CLS - this.lcp = mergeUniqueArrays(lcp, ['theming']).map((componentName) => ({ - componentName: componentName.trim(), - })); - }, - - setStructure() { - const structureComponents = getMetaGroup(metaTags.structure.metaNamePrefix, { getFallback: false }); - this.structureComponents = structureComponents.flatMap(({ name, content }) => { - if (content !== true) return []; - return { - componentName: name.trim(), - }; - }); const template = getMeta(metaTags.template.metaName); - if(template) { - this.structureComponents = [...this.structureComponents, { - componentName: template, - }]; - } - }, - - setLcpBlocks() { - this.lcpBlocks = this.blocksData.filter((data) => !!this.findLcp(data)); - }, - - setLazyBlocks() { - const allLazy = this.blocksData.filter((data) => !this.findLcp(data)); - const { grids, blocks } = getBlocksAndGrids(allLazy); - - this.lazyBlocks = blocks; - this.grids = grids; - }, + const templateConfig = getMeta(metaTags.templateConfig.metaName); - findLcp(data) { - return ( - this.lcp.find(({ componentName }) => componentName === data.componentName) || data.lcp /* || - [...document.querySelectorAll('main > div > [class]:nth-child(-n+1)')].find((el) => el === data?.targets?.[0]) */ - ); - }, - - async initBlocks() { - // Keep the page hidden until specific components are initialized to prevent CLS - component.multiInit(this.lcpBlocks).then(() => { - window.postMessage({ message: 'raqn:components:loaded' }); - document.body.style.setProperty('display', 'block'); + component.init({ + componentName: template, + path: '/templates', + externalConfigName: templateConfig, + targets: [document.querySelector('body > main')], }); - - await component.multiInit(this.lazyBlocks); - // grids must be initialized sequentially starting from the deepest level. - // all the blocks that will be contained by the grids must be already initialized before they are added to the grids. - component.multiSequentialInit(this.grids); }, }; export const globalInit = { async init() { + this.isPreview(); this.setLang(); this.initEagerImages(); onLoadComponents.init(); @@ -228,6 +127,13 @@ export const globalInit = { eagerImage(document.body, length); } }, + + isPreview() { + const { hostname } = window.location; + const previewHosts = ['localhost', '.aem.page']; + + window.raqnIsPreview = previewHosts.some((host) => hostname.endsWith(host)); + }, }; globalInit.init(); diff --git a/scripts/libs.js b/scripts/libs.js index ecd0b011..d8932cc3 100644 --- a/scripts/libs.js +++ b/scripts/libs.js @@ -2,9 +2,9 @@ export const globalConfig = { semanticBlocks: ['header', 'footer'], blockSelector: ` [class]:not( + [raqnwebcomponent], style, - [class^="config-" i], - [class^="grid-item" i] + [class^="config-" i] )`, breakpoints: { xs: 0, @@ -50,8 +50,13 @@ export const metaTags = { }, template: { metaName: 'template', + fallbackContent: 'template', // contentType: 'string template name', }, + templateConfig: { + metaName: 'template-config', + // contentType: 'string template config name', + }, lcp: { metaName: 'lcp', fallbackContent: ['theming', 'header', 'breadcrumbs'], @@ -319,6 +324,80 @@ export function deepMerge(origin, ...toMerge) { return deepMerge(origin, ...toMerge); } +/** + * 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 deepMergeMethod(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 (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]: {} }); + } + deepMergeMethod({ 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 deepMergeMethod({ pathsArrays, currentPath }, origin, ...toMerge); +} + export function loadModule(urlWithoutExtension, loadCSS = true) { try { const js = import(`${urlWithoutExtension}.js`); @@ -327,9 +406,15 @@ export function loadModule(urlWithoutExtension, loadCSS = true) { const cssHref = `${urlWithoutExtension}.css`; if (!document.querySelector(`head > link[href="${cssHref}"]`)) { const link = document.createElement('link'); - link.rel = 'stylesheet'; link.href = cssHref; - link.onload = resolve; + // make the css loading not be render blocking + link.rel = 'preload'; + link.as = 'style'; + link.onload = () => { + link.onload = null; + link.rel = 'stylesheet'; + resolve(); + }; link.onerror = reject; document.head.append(link); } else { diff --git a/scripts/libs/external-config.js b/scripts/libs/external-config.js index ad494878..54ab103f 100644 --- a/scripts/libs/external-config.js +++ b/scripts/libs/external-config.js @@ -4,10 +4,12 @@ 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, category = '') { + const defaultConfig = configName ?? 'default'; + const componentNameCategory = `${category ? `${category}-` : ''}${componentName}`; window.raqnComponentsMasterConfig ??= await this.loadConfig(); - const componentConfig = window.raqnComponentsMasterConfig?.[componentName]; - const parsedConfig = componentConfig?.[configName]; + const componentConfig = window.raqnComponentsMasterConfig?.[componentNameCategory]; + const parsedConfig = componentConfig?.[defaultConfig]; // return copy of object to prevent mutation of raqnComponentsMasterConfig; if (parsedConfig) return deepMerge({}, parsedConfig); diff --git a/styles/styles.css b/styles/styles.css index afb13051..5cef2d78 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -188,7 +188,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; } @@ -253,6 +253,18 @@ button { pointer-events: none; } +.error-message-box { + background: red; + padding-block: 20px; + padding-inline: 20px; + border: 5px solid #000; + margin-block: 20px; + + & * { + color: white; + } +} + #franklin-svg-sprite { display: none; } diff --git a/templates/dynamic-sidebar/dynamic-sidebar.css b/templates/dynamic-sidebar/dynamic-sidebar.css new file mode 100644 index 00000000..56e268dc --- /dev/null +++ b/templates/dynamic-sidebar/dynamic-sidebar.css @@ -0,0 +1,3 @@ +:where(raqn-header, raqn-footer) > raqn-section > *:not(.full-width) { + margin-inline: var(--container-width); +} \ No newline at end of file diff --git a/templates/dynamic-sidebar/dynamic-sidebar.js b/templates/dynamic-sidebar/dynamic-sidebar.js new file mode 100644 index 00000000..e2db6892 --- /dev/null +++ b/templates/dynamic-sidebar/dynamic-sidebar.js @@ -0,0 +1,154 @@ +import Grid from '../../blocks/grid/grid.js'; +import ComponentBase from '../../scripts/component-base.js'; +import component from '../../scripts/init.js'; +import { globalConfig, metaTags, getMetaGroup } from '../../scripts/libs.js'; + +export default class Template extends ComponentBase { + static observedAttributes = [...Grid.observedAttributes]; + + attributesValues = { + grid: { + all: { + class: 'full-width', + data: { + columns: '300px 1fr', + }, + }, + m: { + columns: '1fr', + }, + s: { + columns: '1fr', + }, + xs: { + columns: '1fr', + }, + }, + }; + + static loaderConfig = { + ...ComponentBase.loaderConfig, + targetsSelectors: ':scope > main', + }; + + nestedComponentsConfig = {}; + + extendConfig() { + return [ + ...super.extendConfig(), + { + innerComponents: `.template-sidebar ${globalConfig.blockSelector}, .template-main > div`, + addToTargetMethod: 'append', + targetsAsContainers: { + addToTargetMethod: 'append', + }, + structure: { + breadcrumbs: { + targetsSelectors: 'main', + }, + }, + }, + ]; + } + + setDefaults() { + super.setDefaults(); + this.category = 'template'; + } + + async onInit() { + this.initLCP(); + } + + async addEDSHtml() { + await this.createTemplateGrid(); + } + + async addHtml() { + await this.initStructure(); + } + + ready() { + this.initFooter(); + } + + initLCP() { + component + .multiInit([ + { + componentName: 'theming', + targets: [document.head], + loaderConfig: { + addToTargetMethod: 'append', + }, + }, + { + componentName: 'header', + targets: [document.body], + loaderConfig: { + targetsSelectors: ':scope > header', + targetsAsContainers: true, + }, + }, + ]) + .then(() => { + window.postMessage({ message: 'raqn:components:loaded' }); + document.body.style.setProperty('display', 'block'); + }); + } + + async createTemplateGrid() { + await component.multiLoadAndDefine(['grid', 'grid-item']); + const content = [...this.childNodes]; + this.tplGrid = document.createElement('raqn-grid'); + this.tplSidebar = document.createElement('raqn-grid-item'); + this.tplMain = document.createElement('raqn-grid-item'); + this.tplSidebar.classList.add('template-sidebar'); + this.tplMain.classList.add('template-main'); + + this.tplGrid.attributesValues = this.attributesValues.grid; + this.tplGrid.config.innerComponents = null; + // this.tplGrid.dataset.columns = '300px 1fr'; + this.tplMain.config.innerComponents = null; + this.tplSidebar.config.innerComponents = null; + + this.innerHTML = ''; + this.append(this.tplGrid); + + this.tplGrid.append(this.tplSidebar, this.tplMain); + + await this.tplSidebar.initialization; + await this.tplMain.initialization; + + this.tplMain.append(...content); + } + + async initStructure() { + const structureComponents = getMetaGroup(metaTags.structure.metaNamePrefix, { getFallback: false }); + this.structureComponents = structureComponents.flatMap(({ name, content }) => { + if (content !== true) return []; + const { targetsSelectors } = this.config.structure[name] || {}; + return { + componentName: name.trim(), + targets: [document], + loaderConfig: { + ...(targetsSelectors && { targetsSelectors }), + targetsAsContainers: true, + }, + }; + }); + + await component.multiInit(this.structureComponents); + } + + initFooter() { + return component.init({ + componentName: 'footer', + targets: [document.body], + loaderConfig: { + targetsSelectors: ':scope > footer', + targetsAsContainers: true, + }, + }); + } +} diff --git a/templates/template/template.css b/templates/template/template.css new file mode 100644 index 00000000..627a8f12 --- /dev/null +++ b/templates/template/template.css @@ -0,0 +1,3 @@ +:where(raqn-template, raqn-header, raqn-footer) > raqn-section > *:not(.full-width) { + margin-inline: var(--container-width); +} diff --git a/templates/template/template.js b/templates/template/template.js new file mode 100644 index 00000000..cacea736 --- /dev/null +++ b/templates/template/template.js @@ -0,0 +1,99 @@ +import ComponentBase from '../../scripts/component-base.js'; +import component from '../../scripts/init.js'; +import { metaTags, getMetaGroup } from '../../scripts/libs.js'; + +export default class Template extends ComponentBase { + static loaderConfig = { + ...ComponentBase.loaderConfig, + targetsSelectors: ':scope > main', + }; + + nestedComponentsConfig = {}; + + extendConfig() { + return [ + ...super.extendConfig(), + { + // only init EDS sections + innerComponents: ':scope > div', + addToTargetMethod: 'append', + targetsAsContainers: { + addToTargetMethod: 'append', + }, + structure: { + breadcrumbs: { + targetsSelectors: 'main', + }, + }, + }, + ]; + } + + setDefaults() { + super.setDefaults(); + this.category = 'template'; + } + + async onInit() { + this.initLCP(); + await this.initStructure(); + } + + initLCP() { + component + .multiInit([ + { + componentName: 'theming', + targets: [document.head], + loaderConfig: { + addToTargetMethod: 'append', + }, + }, + { + componentName: 'header', + targets: [document.body], + loaderConfig: { + targetsSelectors: ':scope > header', + targetsAsContainers: true, + }, + }, + ]) + .then(() => { + window.postMessage({ message: 'raqn:components:loaded' }); + document.body.style.setProperty('display', 'block'); + }); + } + + async initStructure() { + const structureComponents = getMetaGroup(metaTags.structure.metaNamePrefix, { getFallback: false }); + this.structureComponents = structureComponents.flatMap(({ name, content }) => { + if (content !== true) return []; + const { targetsSelectors } = this.config.structure[name] || {}; + return { + componentName: name.trim(), + targets: [document], + loaderConfig: { + ...(targetsSelectors && { targetsSelectors }), + targetsAsContainers: true, + }, + }; + }); + + await component.multiInit(this.structureComponents); + } + + initFooter() { + return component.init({ + componentName: 'footer', + targets: [document.body], + loaderConfig: { + targetsSelectors: ':scope > footer', + targetsAsContainers: true, + }, + }); + } + + ready() { + this.initFooter(); + } +}