diff --git a/blocks/breadcrumbs/breadcrumbs.js b/blocks/breadcrumbs/breadcrumbs.js index d35d88e5..b74ab36b 100644 --- a/blocks/breadcrumbs/breadcrumbs.js +++ b/blocks/breadcrumbs/breadcrumbs.js @@ -4,7 +4,7 @@ import { getBaseUrl } from '../../scripts/libs.js'; export default class Breadcrumbs extends ComponentBase { static loaderConfig = { ...ComponentBase.loaderConfig, - targetsSelectors: 'main > div', + targetsSelectors: 'main > div:first-child', targetsSelectorsLimit: 1, }; diff --git a/blocks/columns/columns.css b/blocks/columns/columns.css deleted file mode 100644 index 1879b40e..00000000 --- a/blocks/columns/columns.css +++ /dev/null @@ -1,3 +0,0 @@ -raqn-column { - margin: var(--scope-margin, 0); -} diff --git a/blocks/columns/columns.js b/blocks/columns/columns.js deleted file mode 100644 index 1eb9b63c..00000000 --- a/blocks/columns/columns.js +++ /dev/null @@ -1,76 +0,0 @@ -import { collectAttributes } from '../../scripts/libs.js'; - -export default class Columns { - static observedAttributes = ['data-position', 'data-size', 'data-justify']; - - constructor(data) { - this.element = data.target; - - const { currentAttributes } = collectAttributes( - data.componentName, - data.rawClasses, - Columns.observedAttributes, - this.element, - ); - - Object.keys(currentAttributes).forEach((key) => { - this.element.setAttribute(key, currentAttributes[key]); - }); - - this.position = parseInt(this.element.dataset.position, 10); - this.element.dataset.justify ??= 'stretch'; - this.calculateGridTemplateColumns(); - } - - calculateGridTemplateColumns() { - if (this.justify) { - this.element.style.justifyContent = this.justify; - } - if (this.position) { - const parent = this.element.parentElement; - const children = Array.from(parent.children); - parent.classList.add('raqn-grid'); - let parentGridTemplateColumns = parent.style.getPropertyValue('--grid-template-columns'); - if (!parentGridTemplateColumns) { - // we have no grid template columns yet - parentGridTemplateColumns = children - .map((child, index) => { - if (this.position === index + 1) { - return this.element.dataset.size || 'auto'; - } - return 'auto'; - }) - .join(' '); - // set the new grid template columns - parent.style.setProperty('--grid-template-columns', parentGridTemplateColumns); - } else { - const { position } = this; - const prio = children.indexOf(this.element) + 1; - parentGridTemplateColumns = parentGridTemplateColumns - .split(' ') - .map((size, i) => { - // we have a non standard value for this position - const hasValue = size !== 'auto'; - // we are at the position - const isPosition = i + 1 === position; - // we are at a position before the prio - const isBeforePrio = i + 1 <= prio; - // we have a non standard value for this position and we are at the position - if (!hasValue && isPosition) { - return this.element.dataset.size || 'auto'; - } - // we have a non standard value for this position and we are at a position before the prio - if (hasValue && isPosition && isBeforePrio) { - return this.element.dataset.size || size; - } - return size; - }) - .join(' '); - // set the new grid template columns - parent.style.setProperty('--grid-template-columns', parentGridTemplateColumns); - } - this.element.style.gridColumn = this.position; - this.element.style.gridRow = 1; - } - } -} diff --git a/blocks/footer/footer.js b/blocks/footer/footer.js index d9ce7c23..452e5037 100644 --- a/blocks/footer/footer.js +++ b/blocks/footer/footer.js @@ -4,7 +4,14 @@ import { getMeta } from '../../scripts/libs.js'; const metaFooter = getMeta('footer'); const metaFragment = !!metaFooter && `${metaFooter}.plain.html`; export default class Footer extends ComponentBase { - fragment = metaFragment || 'footer.plain.html'; + static loaderConfig = { + ...ComponentBase.loaderConfig, + loaderStopInit() { + return metaFooter === false; + }, + }; + + fragmentPath = metaFragment || 'footer.plain.html'; extendConfig() { return [ @@ -15,10 +22,6 @@ export default class Footer extends ComponentBase { ]; } - static earlyStopRender() { - return metaFooter === false; - } - ready() { const child = this.children[0]; child.replaceWith(...child.children); diff --git a/blocks/header/header.js b/blocks/header/header.js index c1ef9540..0a9209e3 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -4,7 +4,14 @@ import { eagerImage, getMeta } from '../../scripts/libs.js'; const metaHeader = getMeta('header'); const metaFragment = !!metaHeader && `${metaHeader}.plain.html`; export default class Header extends ComponentBase { - fragment = metaFragment || 'header.plain.html'; + static loaderConfig = { + ...ComponentBase.loaderConfig, + loaderStopInit() { + return metaHeader === false; + }, + }; + + fragmentPath = metaFragment || 'header.plain.html'; dependencies = ['navigation', 'image']; @@ -17,10 +24,6 @@ export default class Header extends ComponentBase { ]; } - static earlyStopRender() { - return metaHeader === false; - } - connected() { eagerImage(this, 1); } diff --git a/blocks/icon/icon.js b/blocks/icon/icon.js index f9611761..49ca9535 100644 --- a/blocks/icon/icon.js +++ b/blocks/icon/icon.js @@ -39,8 +39,15 @@ export default class Icon extends ComponentBase { async connected() { this.setAttribute('aria-hidden', 'true'); + } + + onAttributeIconChanged({ oldValue, newValue }) { + if (oldValue === newValue) return; + this.loadIcon(newValue); + } - this.iconName = this.dataset.icon; + async loadIcon(icon) { + this.iconName = icon; if (!this.cache[this.iconName]) { this.cache[this.iconName] = { loading: new Promise((resolve) => { diff --git a/blocks/image/image.js b/blocks/image/image.js index 24a5f0ca..74aa774a 100644 --- a/blocks/image/image.js +++ b/blocks/image/image.js @@ -5,7 +5,7 @@ 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), + selectorTest: (el) => [el.childNodes.length, el.childNodes[0].childNodes.length].every((len) => len === 1), targetsAsContainers: true, }; diff --git a/blocks/navigation/navigation.js b/blocks/navigation/navigation.js index 80e6a893..e7a97191 100644 --- a/blocks/navigation/navigation.js +++ b/blocks/navigation/navigation.js @@ -9,30 +9,6 @@ export default class Navigation extends ComponentBase { targetsSelectors: ':scope > :is(:first-child)', }; - attributesValues = { - compact: { - xs: 'true', - s: 'true', - m: 'true', - all: 'false', - }, - }; - - createButton() { - this.navButton = document.createElement('button'); - this.navButton.setAttribute('aria-label', 'Menu'); - this.navButton.setAttribute('aria-expanded', 'false'); - this.navButton.setAttribute('aria-controls', 'navigation'); - this.navButton.setAttribute('aria-haspopup', 'true'); - this.navButton.setAttribute('type', 'button'); - this.navButton.innerHTML = ''; - this.navButton.addEventListener('click', () => { - this.classList.toggle('active'); - this.navButton.setAttribute('aria-expanded', this.classList.contains('active')); - }); - return this.navButton; - } - async ready() { this.active = {}; this.navContent = this.querySelector('ul'); @@ -90,18 +66,34 @@ export default class Navigation extends ComponentBase { } } - createIcon(name = this.icon) { - const icon = document.createElement('raqn-icon'); - icon.setAttribute('icon', name); - return icon; + onAttributeIconChanged({ newValue }) { + if (!this.initialized) return; + if (!this.isCompact) return; + this.navIcon.dataset.icon = newValue; + } + + createButton() { + this.navButton = document.createElement('button'); + this.navButton.setAttribute('aria-label', 'Menu'); + this.navButton.setAttribute('aria-expanded', 'false'); + this.navButton.setAttribute('aria-controls', 'navigation'); + this.navButton.setAttribute('aria-haspopup', 'true'); + this.navButton.setAttribute('type', 'button'); + this.navButton.innerHTML = ``; + this.navIcon = this.navButton.querySelector('raqn-icon'); + this.navButton.addEventListener('click', () => { + this.classList.toggle('active'); + this.navButton.setAttribute('aria-expanded', this.classList.contains('active')); + }); + return this.navButton; } addIcon(elem) { component.init({ componentName: 'icon', targets: [elem], - rawClasses: 'icon-chevron-right', - config: { + configByClasses: 'icon-chevron-right', + componentConfig: { addToTargetMethod: 'append', }, }); @@ -111,7 +103,7 @@ export default class Navigation extends ComponentBase { component.init({ componentName: 'accordion', targets: [elem], - config: { + componentConfig: { addToTargetMethod: 'append', }, nestedComponentsConfig: { diff --git a/blocks/section-metadata/section-metadata.js b/blocks/section-metadata/section-metadata.js index 6f12ef74..18994efc 100644 --- a/blocks/section-metadata/section-metadata.js +++ b/blocks/section-metadata/section-metadata.js @@ -1,27 +1,30 @@ -import { collectAttributes } from '../../scripts/libs.js'; import ComponentBase from '../../scripts/component-base.js'; +import { stringToArray } from '../../scripts/libs.js'; -// TODO the block for this component should not have content, the values should come only form class attribute as for any other component -// as for any other block. should replace the this.parentElement export default class SectionMetadata extends ComponentBase { - async ready() { - const classes = [...this.querySelectorAll(':scope > div > div:first-child')].map( - (keyCell) => `${keyCell.textContent.trim()}-${keyCell.nextElementSibling.textContent.trim()}`, - ); + static observedAttributes = ['class']; - const { currentAttributes } = collectAttributes( - 'section-metadata', - classes, - SectionMetadata.observedAttributes, - this, - ); - const section = this.parentElement; - Object.keys(currentAttributes).forEach((key) => { - if (key === 'class') { - section.setAttribute(key, currentAttributes[key]); - } else { - section.setAttribute(`data-${key}`, currentAttributes[key]); - } - }); + extendConfig() { + return [ + ...super.extendConfig(), + { + classes: { + section: 'section', + }, + }, + ]; + } + + ready() { + this.parentElement.classList.add(this.config.classes.section, ...this.classList.values()); + } + + onAttributeClassChanged({ oldValue, newValue }) { + if (!this.initialized) return; + if (oldValue === newValue) return; + + const opts = { divider: ' ' }; + this.parentElement.classList.remove(...stringToArray(oldValue, opts)); + this.parentElement.classList.add(...stringToArray(newValue, opts)); } } diff --git a/blocks/theming/theming.js b/blocks/theming/theming.js index dc2fcd4f..607c005b 100644 --- a/blocks/theming/theming.js +++ b/blocks/theming/theming.js @@ -9,11 +9,11 @@ export default class Theming extends ComponentBase { nestedComponentsConfig = {}; - constructor() { - super(); + setDefaults() { + super.setDefaults(); this.scapeDiv = document.createElement('div'); // keep as it is - this.fragment = metaFragment || 'theming.json'; + this.fragmentPath = metaFragment || 'theming.json'; this.skip = ['tags']; this.toTags = [ 'font-size', diff --git a/head.html b/head.html index 9711645d..92697385 100644 --- a/head.html +++ b/head.html @@ -12,17 +12,18 @@ }); const headerMeta = document.querySelector('meta[name="header"]'); - const link = document.createElement('link'); - link.setAttribute('rel', 'preload'); - link.setAttribute('as', 'fetch'); - link.setAttribute('crossorigin', 'anonymous'); const url = headerMeta?.content.trim(); - link.href = url ? `${url}.plain.html` : 'header.plain.html'; - document.head.appendChild(link); + if (url?.toLowerCase() !== 'false') { + const link = document.createElement('link'); + link.setAttribute('rel', 'preload'); + link.setAttribute('as', 'fetch'); + link.setAttribute('crossorigin', 'anonymous'); + link.href = url ? `${url}.plain.html` : 'header.plain.html'; + document.head.appendChild(link); + } - diff --git a/scripts/component-base.js b/scripts/component-base.js index 79bc3e83..fe575a1f 100644 --- a/scripts/component-base.js +++ b/scripts/component-base.js @@ -1,23 +1,15 @@ import component from './init.js'; - -import { getBreakPoints, listenBreakpointChange, camelCaseAttr, capitalizeCaseAttr, deepMerge } from './libs.js'; +import { + getBreakPoints, + listenBreakpointChange, + camelCaseAttr, + capitalizeCaseAttr, + deepMerge, + buildConfig, +} from './libs.js'; export default class ComponentBase extends HTMLElement { - - constructor() { - super(); - this.uuid = `gen${crypto.randomUUID().split('-')[0]}`; - this.componentName = null; // set by component loader - this.webComponentName = null; // set by component loader - this.fragment = false; - this.dependencies = []; - this.breakpoints = getBreakPoints(); - this.initError = null; - this.attributesValues = {}; // the values are set by the component loader - this.setConfig('config', 'extendConfig'); - this.setConfig('nestedComponentsConfig', 'extendNestedConfig'); - this.setBinds(); - } + static observedAttributes = []; static loaderConfig = { targetsSelectorsPrefix: null, @@ -25,42 +17,74 @@ export default class ComponentBase extends HTMLElement { selectorTest: null, // a function to filter elements matched by targetsSelectors targetsSelectorsLimit: null, targetsAsContainers: false, + loaderStopInit() { + return false; + }, }; - static async earlyStopRender() { - return false; + get Handler() { + return window.raqnComponents[this.componentName]; + } + + constructor() { + super(); + this.setDefaults(); + this.extendConfigRunner({ config: 'config', method: 'extendConfig' }); + this.extendConfigRunner({ config: 'nestedComponentsConfig', method: 'extendNestedConfig' }); + this.setBinds(); } - attributesValues = {}; // the values are set by the component loader + setDefaults() { + this.uuid = `gen${crypto.randomUUID().split('-')[0]}`; + this.webComponentName = this.tagName.toLowerCase(); + this.componentName = this.webComponentName.replace(/^raqn-/, ''); + this.fragmentPath = null; + this.dependencies = []; + this.attributesValues = {}; + this.childComponents = { + // using the nested feature + nestedComponents: [], + // from inner html blocks + innerComponents: [], + }; + this.nestedComponents = []; + this.innerBlocks = []; + this.innerComponents = []; + this.initError = null; + this.breakpoints = getBreakPoints(); - config = { - hideOnInitError: true, - hideOnNestedError: false, - addToTargetMethod: 'replaceWith', - contentFromTargets: true, - targetsAsContainers: { + // use the this.extendConfig() method to extend the default config + this.config = { + hideOnInitError: true, + hideOnChildrenError: false, addToTargetMethod: 'replaceWith', - }, - }; + contentFromTargets: true, + targetsAsContainers: { + addToTargetMethod: 'replaceWith', + }, + }; - // Default values are set by component loader - nestedComponentsConfig = { - image: { - componentName: 'image', - }, - button: { - componentName: 'button', - }, - columns: { - componentName: 'columns', - active: false, - loaderConfig: { - targetsAsContainers: false, + // use the this.extendNestedConfig() method to extend the default config + this.nestedComponentsConfig = { + image: { + componentName: 'image', }, - }, - }; + button: { + componentName: 'button', + }, + columns: { + componentName: 'columns', + active: false, + loaderConfig: { + targetsAsContainers: false, + }, + }, + }; + } - setConfig(config, method) { + // 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); @@ -78,13 +102,152 @@ export default class ComponentBase extends HTMLElement { this.onBreakpointChange = this.onBreakpointChange.bind(this); } + // ! Needs to be called after the element is created; + async init(initOptions) { + try { + this.initOptions = initOptions || {}; + const { externalConfigName, configByClasses } = this.initOptions; + + this.externalOptions = await buildConfig( + this.componentName, + externalConfigName, + configByClasses, + this.Handler.observedAttributes, + ); + + this.mergeConfigs(); + this.setAttributesClassesAndProps(); + this.addDefaultsToNestedConfig(); + // Add extra functionality to be run on init. + await this.onInit(); + this.addContentFromTarget(); + 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); + } + } + } + + async connectComponent() { + const { uuid } = this; + this.setAttribute('isloading', ''); + const initialized = new Promise((resolve, reject) => { + const initListener = async (event) => { + const { error } = event.detail; + this.removeEventListener(`initialized:${uuid}`, initListener); + this.removeAttribute('isloading'); + if (error) { + reject(error); + } + resolve(this); + }; + this.addEventListener(`initialized:${uuid}`, initListener); + }); + const { targetsAsContainers } = deepMerge({}, this.Handler.loaderConfig, this.loaderConfig); + const conf = this.config; + const addToTargetMethod = targetsAsContainers ? conf.targetsAsContainers.addToTargetMethod : conf.addToTargetMethod; + this.initOptions.target[addToTargetMethod](this); + + return initialized; + } + + // Build-in method called after the element is added to the DOM. + async connectedCallback() { + try { + this.initialized = this.getAttribute('initialized'); + this.initSubscriptions(); // must subscribe each time the element is added to the document + if (!this.initialized) { + this.setAttribute('id', this.uuid); + this.loadDependencies(); // do not wait for dependencies; + await this.loadFragment(this.fragmentPath); + await this.connected(); // manipulate/create the html + await this.initChildComponents(); + this.addListeners(); // html is ready add listeners + await this.ready(); // add extra functionality + this.setAttribute('initialized', true); + this.initialized = true; + this.dispatchEvent(new CustomEvent(`initialized:${this.uuid}`, { detail: { element: this } })); + } + } catch (error) { + this.dispatchEvent(new CustomEvent(`initialized:${this.uuid}`, { detail: { error } })); + this.initError = error; + this.hideWithError(this.config.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); + } + } + + mergeConfigs() { + this.props = deepMerge({}, this.initOptions.props, this.externalOptions.props); + + this.config = deepMerge({}, this.config, this.initOptions.componentConfig, this.externalOptions.config); + + this.attributesValues = deepMerge( + this.attributesValues, + this.initOptions.attributesValues, + this.externalOptions.attributesValues, + ); + + this.nestedComponentsConfig = deepMerge( + this.nestedComponentsConfig, + this.initOptions.nestedComponentsConfig, + this.externalOptions.nestedComponentsConfig, + ); + } + + setAttributesClassesAndProps() { + Object.entries(this.props).forEach(([prop, value]) => { + this[prop] = value; + }); + // Set attributes based on attributesValues + Object.entries(this.attributesValues).forEach(([attr, attrValues]) => { + const isClass = attr === 'class'; + const val = (attrValues[this.breakpoints.active.name] ?? attrValues.all); + if (isClass) { + const classes = (attrValues.all ? `${attrValues.all} ` : '') + (attrValues[this.breakpoints.active.name] ?? ''); + const classesArr = classes.split(' ').flatMap((cls) => { + if (cls) return cls.trim(); + return []; + }); + if (!classesArr.length) return; + this.classList.add(...classesArr); + } else { + this.dataset[attr] = val; + } + }); + } + + addDefaultsToNestedConfig() { + Object.keys(this.nestedComponentsConfig).forEach((key) => { + const defaults = { + targets: [this], + active: true, + loaderConfig: { + targetsAsContainers: true, + }, + }; + this.nestedComponentsConfig[key] = deepMerge(defaults, this.nestedComponentsConfig[key]); + }); + } + + addContentFromTarget() { + const { target } = this.initOptions; + const { contentFromTargets } = this.config; + if (!contentFromTargets) return; + + this.append(...target.childNodes); + } + onBreakpointChange(e) { if (e.matches) { this.setBreakpointAttributesValues(e); } } - // TODO change to dataset attributes setBreakpointAttributesValues(e) { Object.entries(this.attributesValues).forEach(([attribute, breakpointsValues]) => { const isAttribute = attribute !== 'class'; @@ -140,47 +303,39 @@ export default class ComponentBase extends HTMLElement { listenBreakpointChange(this.onBreakpointChange); } - async connectedCallback() { - try { - this.initialized = this.getAttribute('initialized'); - this.initSubscriptions(); // must subscribe each time the element is added to the document - if (!this.initialized) { - this.setAttribute('id', this.uuid); - this.loadDependencies(); // do not wait for dependencies; - await this.loadFragment(this.fragment); - await this.connected(); // manipulate/create the html - await this.initNestedComponents(); - this.addListeners(); // html is ready add listeners - await this.ready(); // add extra functionality - this.setAttribute('initialized', true); - this.initialized = true; - this.dispatchEvent(new CustomEvent(`initialized:${this.uuid}`, { detail: { element: this } })); - } - } catch (error) { - this.dispatchEvent(new CustomEvent(`initialized:${this.uuid}`, { detail: { error } })); - this.initError = error; - this.hideWithError(this.config.hideOnInitError, 'has-nested-error'); - } + async initChildComponents() { + await Promise.allSettled([this.initNestedComponents(), this.initInnerBlocks()]); } async initNestedComponents() { - const settings = Object.values(this.nestedComponentsConfig).flatMap((setting) => { + if (!Object.keys(this.nestedComponentsConfig).length) return; + const nestedSettings = Object.values(this.nestedComponentsConfig).flatMap((setting) => { if (!setting.active) return []; - return this.fragment + return this.innerBlocks.length ? deepMerge({}, setting, { - // Content can contain blocks which are going to init their own nestedComponents. + // 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. }, }) : setting; }); - this.nestedComponents = await component.multiInit(settings); - const { - nestedComponents: { allInitialized }, - config: { hideOnNestedError }, - } = this; - this.hideWithError(!allInitialized && hideOnNestedError, 'has-nested-error'); + + this.childComponents.nestedComponents = await component.multiInit(nestedSettings); + + const { allInitialized } = this.childComponents.nestedComponents; + const { hideOnChildrenError } = this.config; + this.hideWithError(!allInitialized && hideOnChildrenError, 'has-nested-error'); + } + + async initInnerBlocks() { + if (!this.innerBlocks.length) return; + const innerBlocksSettings = this.innerBlocks.map((block) => ({ targets: [block] })); + this.childComponents.innerComponents = await component.multiInit(innerBlocksSettings); + + const { allInitialized } = this.childComponents.innerComponents; + const { hideOnChildrenError } = this.config; + this.hideWithError(!allInitialized && hideOnChildrenError, 'has-inner-error'); } async loadDependencies() { @@ -189,7 +344,7 @@ export default class ComponentBase extends HTMLElement { } async loadFragment(path) { - if (!path) return; + if (typeof path !== 'string') return; const response = await this.getFragment(path); await this.processFragment(response); } @@ -202,11 +357,8 @@ export default class ComponentBase extends HTMLElement { if (response.ok) { const html = await response.text(); this.innerHTML = html; - const fragmentNested = await component.multiInit( - [...this.querySelectorAll('div[class]')].map((block) => ({ targets: [block] })), - ); - const { allInitialized } = fragmentNested; - this.hideWithError(!allInitialized && this.config.hideOnNestedError, 'has-nested-error'); + + this.innerBlocks = [...this.querySelectorAll('div[class]')]; } } @@ -219,6 +371,8 @@ export default class ComponentBase extends HTMLElement { initSubscriptions() {} + onInit() {} + connected() {} ready() {} diff --git a/scripts/component-loader.js b/scripts/component-loader.js index fba668b1..98676370 100644 --- a/scripts/component-loader.js +++ b/scripts/component-loader.js @@ -1,7 +1,18 @@ -import { collectAttributes, loadModule, deepMerge, mergeUniqueArrays } from './libs.js'; +import { loadModule, deepMerge, mergeUniqueArrays, getBreakPoints } from './libs.js'; export default class ComponentLoader { - constructor({ componentName, targets = [], loaderConfig, rawClasses, config, nestedComponentsConfig, active }) { + constructor({ + componentName, + targets = [], + loaderConfig, + configByClasses, + attributesValues, + externalConfigName, + componentConfig, + props, + nestedComponentsConfig, + active, + }) { window.raqnComponents ??= {}; if (!componentName) { throw new Error('`componentName` is required'); @@ -9,10 +20,14 @@ export default class ComponentLoader { this.componentName = componentName; this.targets = targets.map((target) => ({ target })); this.loaderConfig = loaderConfig; - this.rawClasses = rawClasses?.trim?.().split?.(' ') || []; - this.config = config; + this.configByClasses = configByClasses?.trim?.().split?.(' ') || []; + this.attributesValues = attributesValues; + this.externalConfigName = externalConfigName; + this.breakpoints = getBreakPoints(); + this.componentConfig = componentConfig; this.nestedComponentsConfig = nestedComponentsConfig; this.pathWithoutExtension = `/blocks/${this.componentName}/${this.componentName}`; + this.props = props ?? {}; this.isWebComponent = null; this.isClass = null; this.isFn = null; @@ -41,16 +56,17 @@ export default class ComponentLoader { if (this.active === false) return []; if (!this.componentName) return []; const { loaded, error } = await this.loadAndDefine(); - if (!loaded) throw new Error(error); + if (!loaded) throw error; this.setHandlerType(); - if (await this.Handler?.earlyStopRender?.()) return []; + this.loaderConfig = deepMerge({}, this.Handler.loaderConfig, this.loaderConfig); + if (await this.loaderConfig?.loaderStopInit?.()) return []; if (!this.targets?.length) return []; this.setTargets(); return Promise.allSettled( - this.targets.map(async (target) => { + this.targets.map(async (targetData) => { let returnVal = null; - const data = this.getTargetData(target); + const data = this.getInitData(targetData); if (this.isWebComponent) { returnVal = this.initWebComponent(data); } @@ -68,25 +84,22 @@ export default class ComponentLoader { } async initWebComponent(data) { - let returnVal = null; + let elem = null; try { - const elem = await this.createElementAndConfigure(data); - data.componentElem = elem; - returnVal = elem; - this.addContentFromTarget(data); - await this.connectComponent(data); + elem = await this.createElementAndConfigure(data); } catch (error) { - const err = new Error(error); - err.elem = returnVal; + error.elem ??= elem; + elem?.classList.add('hide-with-error'); + elem?.setAttribute('has-loader-error', ''); // eslint-disable-next-line no-console console.error( `There was an error while initializing the '${this.componentName}' webComponent:`, - returnVal, + error.elem, error, ); - throw err; + throw error; } - return returnVal; + return elem; } async initClass(data) { @@ -112,17 +125,22 @@ export default class ComponentLoader { } } - getTargetData({ target, container }) { + getInitData({ target, container }) { return { + throwInitError: true, target, container, - rawClasses: !container ? mergeUniqueArrays(this.rawClasses, target.classList) : this.rawClasses, - // content: target?.childNodes, + configByClasses: !container ? mergeUniqueArrays(this.configByClasses, target.classList) : this.configByClasses, + props: this.props, + componentConfig: this.componentConfig, + externalConfigName: this.externalConfigName, + attributesValues: this.attributesValues, + nestedComponentsConfig: this.nestedComponentsConfig, + loaderConfig: this.loaderConfig, }; } setTargets() { - this.loaderConfig = deepMerge({}, this.Handler.loaderConfig, this.loaderConfig); const { targetsSelectorsPrefix, targetsSelectors, targetsSelectorsLimit, targetsAsContainers, selectorTest } = this.loaderConfig; const selector = `${targetsSelectorsPrefix || ''} ${targetsSelectors}`; @@ -154,95 +172,33 @@ export default class ComponentLoader { async createElementAndConfigure(data) { const componentElem = document.createElement(this.webComponentName); - - componentElem.componentName = this.componentName; - componentElem.webComponentName = this.webComponentName; - componentElem.config = deepMerge({}, componentElem.config, this.config); - const { nestedComponentsConfig } = componentElem; - const { currentAttributes, nestedComponents } = collectAttributes( - this.componentName, - data.rawClasses, - this?.Handler?.observedAttributes, - componentElem, - ); - - Object.keys(currentAttributes).forEach((key) => { - const attr = key === 'class' ? key : `data-${key}`; - componentElem.setAttribute(attr, currentAttributes[key].trim()); - }); - - componentElem.nestedComponentsConfig = deepMerge( - componentElem.nestedComponentsConfig, - this.nestedComponentsConfig, - nestedComponents, - ); - - Object.keys(nestedComponentsConfig).forEach((key) => { - const defaults = { - targets: [componentElem], - active: true, - loaderConfig: { - targetsAsContainers: true, - }, - }; - nestedComponentsConfig[key] = deepMerge(defaults, nestedComponentsConfig[key]); - }); - + try { + await componentElem.init(data); + } catch (error) { + error.elem = componentElem; + throw error; + } return componentElem; } - addContentFromTarget(data) { - const { componentElem, target } = data; - const { contentFromTargets } = componentElem.config; - if (!contentFromTargets) return; - - componentElem.append(...target.childNodes); - } - - async connectComponent(data) { - const { componentElem } = data; - const { uuid } = componentElem; - componentElem.setAttribute('isloading', ''); - const initialized = new Promise((resolve, reject) => { - const initListener = async (event) => { - const { error } = event.detail; - componentElem.removeEventListener(`initialized:${uuid}`, initListener); - componentElem.removeAttribute('isloading'); - if (error) { - reject(error); - } - resolve(componentElem); - }; - componentElem.addEventListener(`initialized:${uuid}`, initListener); - }); - const { targetsAsContainers } = this.loaderConfig; - const conf = componentElem.config; - const addToTargetMethod = targetsAsContainers ? conf.targetsAsContainers.addToTargetMethod : conf.addToTargetMethod; - data.target[addToTargetMethod](componentElem); - - return initialized; - } - async loadAndDefine() { try { let cssLoaded = Promise.resolve(); - if (!this.Handler) { - this.Handler = (async () => { - const { css, js } = loadModule(this.pathWithoutExtension); - cssLoaded = css; - const mod = await js; - if (mod.default.prototype instanceof HTMLElement) { - window.customElements.define(this.webComponentName, mod.default); - } - return mod.default; - })(); - } + this.Handler ??= (async () => { + const { css, js } = loadModule(this.pathWithoutExtension); + cssLoaded = css; + const mod = await js; + if (mod.default.prototype instanceof HTMLElement) { + window.customElements.define(this.webComponentName, mod.default); + } + return mod.default; + })(); this.Handler = await this.Handler; await cssLoaded; return { loaded: true }; } catch (error) { // eslint-disable-next-line no-console - console.error(`Failed to load module for ${this.componentName}:`, error); + console.error(`Failed to load module for the '${this.componentName}' component:`, error); return { loaded: false, error }; } } diff --git a/scripts/init.js b/scripts/init.js index 78aea1fd..a8ec1420 100644 --- a/scripts/init.js +++ b/scripts/init.js @@ -28,7 +28,7 @@ const component = { initError: error, }; // eslint-disable-next-line no-console - console.error(`There was an error while initializing the ${componentName} component`, error); + console.error(`There was an error while initializing the '${componentName}' component`, error); return init; } }, @@ -71,10 +71,11 @@ const component = { }; const onLoadComponents = { + // default content staticStructureComponents: [ { componentName: 'image', - block: document, + targets: [document], loaderConfig: { targetsAsContainers: true, targetsSelectorsPrefix: 'main > div >', @@ -82,7 +83,7 @@ const onLoadComponents = { }, { componentName: 'button', - block: document, + targets: [document], loaderConfig: { targetsAsContainers: true, targetsSelectorsPrefix: 'main > div >', @@ -111,7 +112,7 @@ const onLoadComponents = { setBlocksData() { const structureData = this.structureComponents.map(({ componentName }) => ({ componentName, - block: document, + targets: [document], loaderConfig: { targetsAsContainers: true, }, @@ -158,6 +159,7 @@ const onLoadComponents = { }, initBlocks() { + // Keep the page hidden until specific components are initialized to prevent CLS component.multiInit(this.lcpBlocks).then(() => { document.body.style.setProperty('display', 'unset'); }); diff --git a/scripts/libs.js b/scripts/libs.js index 0ab08d5e..2265173f 100644 --- a/scripts/libs.js +++ b/scripts/libs.js @@ -115,7 +115,8 @@ export const eagerImage = (block, length = 1) => { }); }; -export function stringToJsVal(string) { +export function stringToJsVal(string, options) { + const { trim = false } = options || {}; switch (string?.trim().toLowerCase()) { case 'true': return true; @@ -126,10 +127,22 @@ export function stringToJsVal(string) { case 'undefined': return undefined; default: - return string; + return trim ? string.trim() : string; } } +export function stringToArray(val, options) { + const { divider = ',' } = options || {}; + if (typeof val !== 'string') return []; + const cleanVal = val.trim().replace(new RegExp(`^${divider}+|${divider}+$`, 'g'), ''); + if (!cleanVal?.length) return []; + return cleanVal.split(divider).flatMap((x) => { + const value = x.trim(); + if (value === '') return []; + return [value]; + }); +} + export function getMeta(name, settings) { const { getArray = false } = settings || {}; const meta = document.querySelector(`meta[name="${name}"]`); @@ -138,8 +151,7 @@ export function getMeta(name, settings) { } const val = stringToJsVal(meta.content); if (getArray) { - if (!val?.length) return []; - return val.split(',').map((x) => x.trim()); + return stringToArray(val); } return val; } @@ -153,107 +165,251 @@ export function getMetaGroup(group) { })); } -export function collectAttributes(componentName, classes, knownAttributes = [], element = null) { - const classesList = []; - const mediaAttributes = {}; - const attributesValues = element?.attributesValues || {}; - const nestedComponents = {}; - /** - * 1. get all nested components config names - * 2. get all the classes prefixed with the config name - */ - const nestPrefix = 'nest-'; - classes.forEach((c) => { - const isNested = c.startsWith(nestPrefix); - if (isNested) { - const name = c.slice(nestPrefix.length); - nestedComponents[name] = { - componentName: name, - active: true, - /* targets: [element] */ - }; - } else { - classesList.push(c); - } - }); - - const nestedComponentsNames = Object.keys(nestedComponents); +export function isObject(item) { + return item && typeof item === 'object' && !Array.isArray(item); +} - const attrs = classesList - .filter((c) => c !== componentName && c !== 'block') - .reduce((acc, c) => { - let value = c; - let isKnownAttribute = null; +export function isObjectNotWindow(item) { + return isObject(item) && item !== window; +} - const classBreakpoint = Object.keys(globalConfig.breakpoints).find((b) => c.startsWith(`${b}-`)); - const activeBreakpoint = getBreakPoints().active.name; +export function deepMerge(origin, ...toMerge) { + if (!toMerge.length) return origin; + const merge = toMerge.shift(); - if (classBreakpoint) { - value = value.slice(classBreakpoint.length + 1); + if (isObjectNotWindow(origin) && isObjectNotWindow(merge)) { + Object.keys(merge).forEach((key) => { + if (isObjectNotWindow(merge[key])) { + if (!origin[key]) Object.assign(origin, { [key]: {} }); + deepMerge(origin[key], merge[key]); + } else { + Object.assign(origin, { [key]: merge[key] }); } + }); + } - const nested = nestedComponentsNames.find((prefix) => value.startsWith(prefix)); - if (nested) { - nestedComponents[nested].rawClasses ??= ''; - nestedComponents[nested].rawClasses += `${classBreakpoint ? `${classBreakpoint}-` : ''}${value.slice( - nested.length + 1, - )} `; - return acc; - } + return deepMerge(origin, ...toMerge); +} - let key = 'class'; - const isClassValue = value.startsWith(key); - if (isClassValue) { - value = value.slice(key.length + 1); - } else { - [isKnownAttribute] = knownAttributes.flatMap((attribute) => { - const noDataPrefix = attribute.replace(/^data-/, ''); - if (!value.startsWith(`${noDataPrefix}-`)) return []; - return noDataPrefix; - }); - if (isKnownAttribute) { - key = isKnownAttribute; - value = value.slice(isKnownAttribute.length + 1); +export const externalConfig = { + defaultConfig(rawConfig = []) { + return { attributesValues: {}, nestedComponentsConfig: {}, props: {}, config: {}, rawConfig }; + }, + + async getConfig(componentName, configName, knownAttributes) { + if (!configName) return this.defaultConfig(); // to be removed in the feature and fallback to 'default' + const masterConfig = await this.loadConfig(); + const componentConfig = masterConfig?.[componentName]; + let parsedConfig = componentConfig?.parsed?.[configName]; + if (parsedConfig) return parsedConfig; + const rawConfig = componentConfig?.data.filter((conf) => conf.configName?.trim() === configName /* ?? 'default' */); + if (!rawConfig?.length) { + // eslint-disable-next-line no-console + console.error(`The config named '${configName}' for '${componentName}' webComponent is not valid.`); + return this.defaultConfig(); + } + const safeConfig = JSON.parse(JSON.stringify(rawConfig)); + parsedConfig = this.parseRawConfig(safeConfig, knownAttributes); + componentConfig.parsed ??= {}; + componentConfig.parsed[configName] = parsedConfig; + + return parsedConfig; + }, + + async loadConfig() { + window.raqnComponentsConfig ??= (async () => { + const metaConfigPath = getMeta('component-config'); + const defaultConfig = 'components-config.json'; + const configPath = (!!metaConfigPath && `${metaConfigPath}.json`) || defaultConfig; + let result = null; + try { + const response = await fetch(`${configPath}`); + if (response.ok) { + result = await response.json(); } + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); } + return result; + })(); - const isClass = key === 'class'; - const camelCaseKey = camelCaseAttr(key); - if (isKnownAttribute || isClass) attributesValues[camelCaseKey] ??= {}; + window.raqnComponentsConfig = await window.raqnComponentsConfig; - // media params always overwrite - if (classBreakpoint) { - if (classBreakpoint === activeBreakpoint) { - mediaAttributes[key] = value; - } - if (isKnownAttribute) attributesValues[camelCaseKey][classBreakpoint] = value; - if (isClass) { - attributesValues[camelCaseKey][classBreakpoint] ??= ''; - attributesValues[camelCaseKey][classBreakpoint] += `${value} `; + return window.raqnComponentsConfig; + }, + + parseRawConfig(configArr, knownAttributes) { + const parsedConfig = configArr?.reduce((acc, breakpointConfig) => { + const breakpoint = breakpointConfig.viewport.toLowerCase(); + const isMainConfig = breakpoint === 'all'; + + Object.entries(breakpointConfig).forEach(([key, val]) => { + if (val.trim() === '') return; + + const parsedVal = stringToJsVal(val, { trim: true }); + + if (knownAttributes.includes(key) || key === 'class') { + this.parseAttrValues(parsedVal, acc, key, breakpoint); + } else if (isMainConfig) { + const configPrefix = 'config-'; + const propPrefix = 'prop-'; + if (key.startsWith(configPrefix)) { + this.parseConfig(parsedVal, acc, key, configPrefix); + } else if (key.startsWith(propPrefix)) { + acc.props[key.slice(propPrefix.length)] = parsedVal; + } else if (key === 'nest') { + this.parseNestedConfig(val, acc); + } } - // support multivalue attributes - } else if (acc[key]) { - acc[key] += ` ${value}`; - } else { - acc[key] = value; - } + }); + return acc; + }, this.defaultConfig(configArr)); + + return parsedConfig; + }, + + parseAttrValues(parsedVal, acc, key, breakpoint) { + const keyProp = key.replace(/^data-/, ''); + acc.attributesValues[keyProp] ??= {}; + acc.attributesValues[keyProp][breakpoint] = parsedVal; + }, - if ((isKnownAttribute || isClass) && acc[key]) attributesValues[camelCaseKey].all = acc[key]; + parseConfig(parsedVal, acc, key, configPrefix) { + const configKeys = key.slice(configPrefix.length).split('.'); + const indexLength = configKeys.length - 1; + configKeys.reduce((cof, confKey, index) => { + cof[confKey] = index < indexLength ? {} : parsedVal; + return cof[confKey]; + }, acc.config); + }, + + parseNestedConfig(val, acc) { + const parsedVal = stringToArray(val).reduce((nestConf, confVal) => { + const [componentName, activeOrConfigName] = confVal.split('='); + const parsedActiveOrConfigName = stringToJsVal(activeOrConfigName); + const isString = typeof parsedActiveOrConfigName === 'string'; + nestConf[componentName] ??= { + componentName, + externalConfigName: isString ? parsedActiveOrConfigName : null, + active: isString || parsedActiveOrConfigName, + }; + return nestConf; + }, {}); + acc.nestedComponentsConfig = parsedVal; + }, +}; +export const configFromClasses = { + getConfig(componentName, configByClasses, knownAttributes) { + const nestedComponentsConfig = this.nestedConfigFromClasses(configByClasses); + const attributesValues = this.attributeValuesFromClasses(componentName, configByClasses, knownAttributes); + return { + attributesValues, + nestedComponentsConfig, + }; + }, + + nestedComponentsNames(configByClasses) { + const nestPrefix = 'nest-'; // + + return configByClasses.flatMap((c) => (c.startsWith(nestPrefix) ? [c.slice(nestPrefix.length)] : [])); + }, + + nestedConfigFromClasses(configByClasses) { + const nestedComponentsNames = this.nestedComponentsNames(configByClasses); + const nestedComponentsConfig = configByClasses.reduce((acc, c) => { + let value = c; + + const classBreakpoint = this.classBreakpoint(c); + const isBreakpoint = this.isBreakpoint(classBreakpoint); + + if (isBreakpoint) value = value.slice(classBreakpoint.length + 1); + + const componentName = nestedComponentsNames.find((prefix) => value.startsWith(prefix)); + if (componentName) { + acc[componentName] ??= { componentName, active: true }; + const val = value.slice(componentName.length + 1); + const active = 'active-'; + if (val.startsWith(active)) { + acc[componentName].active = stringToJsVal(val.slice(active.length)); + } else { + acc[componentName].configByClasses ??= ''; + acc[componentName].configByClasses += `${isBreakpoint ? `${classBreakpoint}-` : ''}${val} `; + } + } return acc; }, {}); + return nestedComponentsConfig; + }, - return { - currentAttributes: { - ...attrs, - ...mediaAttributes, - ...((attrs.class || mediaAttributes.class) && { - class: `${attrs.class ? attrs.class : ''}${mediaAttributes.class ? ` ${mediaAttributes.class}` : ''}`, - }), - }, - attributesValues, - nestedComponents, - }; + attributeValuesFromClasses(componentName, configByClasses, knownAttributes) { + const nestedComponentsNames = this.nestedComponentsNames(configByClasses); + const onlyKnownAttributes = knownAttributes.filter((a) => a !== 'class'); + const attributesValues = configByClasses + .filter((c) => c !== componentName && c !== 'block') + .reduce((acc, c) => { + let value = c; + let isKnownAttribute = null; + + const classBreakpoint = this.classBreakpoint(c); + const isBreakpoint = this.isBreakpoint(classBreakpoint); + + if (isBreakpoint) value = value.slice(classBreakpoint.length + 1); + + const excludeNested = nestedComponentsNames.find((prefix) => value.startsWith(prefix)); + if (excludeNested) return acc; + + let key = 'class'; + const isClassValue = value.startsWith(key); + if (isClassValue) { + value = value.slice(key.length + 1); + } else { + [isKnownAttribute] = onlyKnownAttributes.flatMap((attribute) => { + const noDataPrefix = attribute.replace(/^data-/, ''); + if (!value.startsWith(`${noDataPrefix}-`)) return []; + return noDataPrefix; + }); + if (isKnownAttribute) { + key = isKnownAttribute; + value = value.slice(isKnownAttribute.length + 1); + } + } + + const isClass = key === 'class'; + const camelCaseKey = camelCaseAttr(key); + if (isKnownAttribute || isClass) acc[camelCaseKey] ??= {}; + if (isKnownAttribute) acc[camelCaseKey][classBreakpoint] = value; + if (isClass) { + acc[camelCaseKey][classBreakpoint] ??= ''; + acc[camelCaseKey][classBreakpoint] += `${value} `; + } + return acc; + }, {}); + + return attributesValues; + }, + classBreakpoint(c) { + return Object.keys(globalConfig.breakpoints).find((b) => c.startsWith(`${b}-`)) || 'all'; + }, + isBreakpoint(classBreakpoint) { + return classBreakpoint !== 'all'; + }, +}; + +export async function buildConfig(componentName, externalConf, configByClasses, knownAttributes = []) { + const configPrefix = 'config-'; + let config; + const externalConfigName = + configByClasses.find((c) => c.startsWith(configPrefix))?.slice?.(configPrefix.length) || externalConf; + + if (externalConfigName) { + config = await externalConfig.getConfig(componentName, externalConfigName, knownAttributes); + } else { + config = configFromClasses.getConfig(componentName, configByClasses, knownAttributes); + } + + return config; } export function loadModule(urlWithoutExtension) { @@ -290,29 +446,3 @@ export function getBaseUrl() { export function isHomePage(url) { return getBaseUrl() === (url || window.location.href); } - -export function isObject(item) { - return item && typeof item === 'object' && !Array.isArray(item); -} - -export function isObjectNotWindow(item) { - return isObject(item) && item !== window; -} - -export function deepMerge(origin, ...toMerge) { - if (!toMerge.length) return origin; - const merge = toMerge.shift(); - - if (isObjectNotWindow(origin) && isObjectNotWindow(merge)) { - Object.keys(merge).forEach((key) => { - if (isObjectNotWindow(merge[key])) { - if (!origin[key]) Object.assign(origin, { [key]: {} }); - deepMerge(origin[key], merge[key]); - } else { - Object.assign(origin, { [key]: merge[key] }); - } - }); - } - - return deepMerge(origin, ...toMerge); -}