From 4b8c4c2a4224dbb8d283c303eb59f3fdbae77d14 Mon Sep 17 00:00:00 2001 From: Florin Raducan Date: Wed, 17 Apr 2024 08:39:05 +0300 Subject: [PATCH] #461424 #461425 - Improve boilerplate and add component nesting feature. Add global linked images and CTA definitions. --- blocks/accordion/accordion.js | 4 +- blocks/breadcrumbs/breadcrumbs.js | 37 ++- blocks/button/button.css | 1 - blocks/button/button.js | 18 +- blocks/card/card.css | 60 +++-- blocks/card/card.js | 3 - blocks/column/column.css | 5 + blocks/column/column.js | 71 ++++++ blocks/columns/columns.css | 3 + blocks/columns/columns.js | 94 +++++++ blocks/footer/footer.js | 19 +- blocks/header/header.js | 23 +- blocks/icon/icon.js | 3 +- blocks/image/image.css | 3 + blocks/image/image.js | 43 ++++ blocks/navigation/navigation.js | 56 ++++- blocks/section-metadata/section-metadata.js | 2 + .../{theme/theme.css => theming/theming.css} | 2 +- blocks/{theme/theme.js => theming/theming.js} | 13 +- head.html | 22 +- scripts/component-base.js | 103 ++++++-- scripts/component-loader.js | 212 ++++++++++++---- scripts/init.js | 236 ++++++++++++------ scripts/libs.js | 138 ++++++++-- styles/styles.css | 10 +- 25 files changed, 933 insertions(+), 248 deletions(-) create mode 100644 blocks/column/column.css create mode 100644 blocks/column/column.js create mode 100644 blocks/columns/columns.css create mode 100644 blocks/columns/columns.js create mode 100644 blocks/image/image.css create mode 100644 blocks/image/image.js rename blocks/{theme/theme.css => theming/theming.css} (55%) rename blocks/{theme/theme.js => theming/theming.js} (91%) diff --git a/blocks/accordion/accordion.js b/blocks/accordion/accordion.js index 2cc41191..eab7d190 100644 --- a/blocks/accordion/accordion.js +++ b/blocks/accordion/accordion.js @@ -15,7 +15,7 @@ export default class Accordion extends ComponentBase { } return child; }); - // console.log(children) + this.setupControls(children.filter((_, ind) => ind % 2 === 0)); this.setupContent(children.filter((_, ind) => ind % 2 === 1)); } @@ -25,7 +25,7 @@ export default class Accordion extends ComponentBase { const icon = document.createElement('raqn-icon'); icon.setAttribute('icon', 'chevron-right'); const children = Array.from(control.children); - if (children.length === 0) { + if (!children.length) { const child = document.createElement('span'); child.textContent = control.textContent; control.innerHTML = ''; diff --git a/blocks/breadcrumbs/breadcrumbs.js b/blocks/breadcrumbs/breadcrumbs.js index fe3b3083..6015b1b9 100644 --- a/blocks/breadcrumbs/breadcrumbs.js +++ b/blocks/breadcrumbs/breadcrumbs.js @@ -1,17 +1,31 @@ import ComponentBase from '../../scripts/component-base.js'; +import { getBaseUrl } from '../../scripts/libs.js'; -export default class BreadCrumbs extends ComponentBase { - capitalize(string) { - return string - .split('-') - .map((str) => str.charAt(0).toUpperCase() + str.slice(1)) - .join(' '); +export default class Breadcrumbs extends ComponentBase { + static loaderConfig = { + ...ComponentBase.loaderConfig, + targetsSelectors: 'main > div', + targetsSelectorsLimit: 1, + targetsAsContainers: true, + }; + + extendConfig() { + return [ + ...super.extendConfig(), + { + contentFromTargets: false, + addToTargetMethod: 'replaceWith', + targetsAsContainers: { + addToTargetMethod: 'prepend', + }, + }, + ]; } - ready() { + connected() { this.classList.add('full-width'); this.classList.add('breadcrumbs'); - this.path = window.location.pathname.split('/'); + this.path = window.location.href.split(getBaseUrl()).join('/').split('/'); this.innerHTML = ` `; } + + capitalize(string) { + return string + .split('-') + .map((str) => str.charAt(0).toUpperCase() + str.slice(1)) + .join(' '); + } } diff --git a/blocks/button/button.css b/blocks/button/button.css index b3b31b2d..c1e60516 100644 --- a/blocks/button/button.css +++ b/blocks/button/button.css @@ -2,7 +2,6 @@ raqn-button { width: 100%; display: grid; align-content: center; - justify-content: center; align-items: center; justify-items: var(--scope-justify, start); } diff --git a/blocks/button/button.js b/blocks/button/button.js index 0647e3d3..636b4da5 100644 --- a/blocks/button/button.js +++ b/blocks/button/button.js @@ -1,8 +1,20 @@ import ComponentBase from '../../scripts/component-base.js'; export default class Button extends ComponentBase { - ready() { - this.setAttribute('role', 'button'); - this.setAttribute('tabindex', '0'); + static loaderConfig = { + ...ComponentBase.loaderConfig, + targetsSelectors: ':is(p,div):has(> a:only-child)', + selectorTest: (el) => el.childNodes.length === 1, + }; + + extendConfig() { + return [ + ...super.extendConfig(), + { + targetsAsContainers: { + addToTargetMethod: 'append', + }, + }, + ]; } } diff --git a/blocks/card/card.css b/blocks/card/card.css index b3f2c32f..3d61bfeb 100644 --- a/blocks/card/card.css +++ b/blocks/card/card.css @@ -8,20 +8,9 @@ raqn-card { padding: var(--scope-padding, 20px 0); } -raqn-card a { - font-size: var(--raqn-font-size-3, 1.2em); - font-weight: bold; -} - -raqn-card p a { - margin-block: 0; -} - -raqn-card > picture { - grid-column: span var(--card-columns, 1fr); -} - raqn-card > div { + display: flex; + gap: var(--scope-gap, 20px); position: relative; background: var(--scope-inner-background, transparent); padding: var(--scope-inner-padding, 20px); @@ -31,33 +20,42 @@ raqn-card > div { border-inline-end: var(--scope-border-inline-end, none); } -raqn-card > div div:last-child > a { +raqn-card :where(a, button) { + position: relative; + z-index: 2; +} + +/* Make entire item clickable */ +raqn-card div > div:first-child > p > em:only-child > a:only-child { position: absolute; - inset-inline-start: 0; - inset-block-start: 0; + inset-block-end: 0; + inset-inline-end: 0; width: 100%; height: 100%; - cursor: pointer; - text-indent: -10000px; - margin: 0; - padding: 0; + color: transparent; + user-select: none; + z-index: 1; } -raqn-card > div:has(raqn-icon) { - padding-block-end: 0; - padding-block-end: var(--scope-icon-size, 20px); +raqn-card div > div:first-child > p:has(> em:only-child > a:only-child) { + margin: 0; } -raqn-card p:has(raqn-icon) { - display: inline-grid; -} -raqn-card raqn-icon { - width: 100%; +raqn-card div > div { display: flex; - position: absolute; + flex-direction: column; + height: 100%; inset-block-end: 0; inset-inline-end: 0; - box-sizing: border-box; - padding: var(--scope-inner-padding, 20px); +} + +raqn-card div > div p:last-child:has(> raqn-button, raqn-icon) { + flex-grow: 1; + display: flex; + align-items: flex-end; +} + +raqn-card div > div p:last-child:has(> raqn-icon) { + justify-content: flex-end; } diff --git a/blocks/card/card.js b/blocks/card/card.js index b2a26d1c..84f76707 100644 --- a/blocks/card/card.js +++ b/blocks/card/card.js @@ -5,9 +5,6 @@ export default class Card extends ComponentBase { static observedAttributes = ['columns', 'ratio', 'eager', 'background', 'button']; ready() { - if (this.getAttribute('button') === 'true') { - Array.from(this.querySelectorAll('a')).forEach((a) => this.convertLink(a)); - } this.eager = parseInt(this.getAttribute('eager') || 0, 10); this.classList.add('inner'); if (this.eager) { diff --git a/blocks/column/column.css b/blocks/column/column.css new file mode 100644 index 00000000..002ec5ac --- /dev/null +++ b/blocks/column/column.css @@ -0,0 +1,5 @@ +raqn-column { + margin: var(--scope-margin, 0); + width: 100%; + display: grid; +} diff --git a/blocks/column/column.js b/blocks/column/column.js new file mode 100644 index 00000000..f3ea5a0d --- /dev/null +++ b/blocks/column/column.js @@ -0,0 +1,71 @@ +import ComponentBase from '../../scripts/component-base.js'; + +// ! Solution 1 to remove mixins +export default class ColumnN extends ComponentBase { + static observedAttributes = ['position', 'size', 'justify']; + + connected() { + this.position = parseInt(this.getAttribute('position'), 10); + this.size = this.getAttribute('size'); + this.justify = this.getAttribute('justify') || 'stretch'; + this.calculateGridTemplateColumns(); + } + + calculateGridTemplateColumns() { + this.style.setProperty('justify-content', this.justify); + if (this.position) { + const parent = this.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.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) + 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.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.size || size; + } + return size; + }) + .join(' '); + // set the new grid template columns + parent.style.setProperty( + '--grid-template-columns', + parentGridTemplateColumns, + ); + } + this.style.gridColumn = this.position; + this.style.gridRow = 1; + } + } +} diff --git a/blocks/columns/columns.css b/blocks/columns/columns.css new file mode 100644 index 00000000..1879b40e --- /dev/null +++ b/blocks/columns/columns.css @@ -0,0 +1,3 @@ +raqn-column { + margin: var(--scope-margin, 0); +} diff --git a/blocks/columns/columns.js b/blocks/columns/columns.js new file mode 100644 index 00000000..3d77e77b --- /dev/null +++ b/blocks/columns/columns.js @@ -0,0 +1,94 @@ +import { collectAttributes } from '../../scripts/libs.js'; + +// ! Solution 2 to remove mixins +export default class Columns { + static observedAttributes = ['position', 'size', '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.getAttribute('position'), 10); + this.size = this.getAttribute('size'); + this.justify = this.getAttribute('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.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.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.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; + } + } + + getAttribute(name) { + return ( + this.element.getAttribute(name) || + this.element.getAttribute(`data-${name}`) + ); + } +} diff --git a/blocks/footer/footer.js b/blocks/footer/footer.js index 32ce6b8c..0d24d733 100644 --- a/blocks/footer/footer.js +++ b/blocks/footer/footer.js @@ -1,8 +1,23 @@ import ComponentBase from '../../scripts/component-base.js'; +import { getMeta } from '../../scripts/libs.js'; +const metaFooter = getMeta('footer'); +const metaFragment = !!metaFooter && `${metaFooter}.plain.html`; export default class Footer extends ComponentBase { - // keep as it is - fragment = 'footer.plain.html'; + fragment = metaFragment || 'footer.plain.html'; + + extendConfig() { + return [ + ...super.extendConfig(), + { + addToTargetMethod: 'append', + }, + ]; + } + + static earlyStopRender() { + return metaFragment === false; + } ready() { const child = this.children[0]; diff --git a/blocks/header/header.js b/blocks/header/header.js index 9d1e29ae..b1e400f3 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -1,14 +1,27 @@ import ComponentBase from '../../scripts/component-base.js'; -import { eagerImage } from '../../scripts/libs.js'; +import { eagerImage, getMeta } from '../../scripts/libs.js'; +const metaHeader = getMeta('header'); +const metaFragment = !!metaHeader && `${metaHeader}.plain.html`; export default class Header extends ComponentBase { - // keep as it is - fragment = 'header.plain.html'; + fragment = metaFragment || 'header.plain.html'; dependencies = ['navigation']; - async processFragment(response) { - await super.processFragment(response); + extendConfig() { + return [ + ...super.extendConfig(), + { + addToTargetMethod: 'append', + }, + ]; + } + + static earlyStopRender() { + return metaFragment === false; + } + + connected() { eagerImage(this, 1); } } diff --git a/blocks/icon/icon.js b/blocks/icon/icon.js index 90bacd1f..72d8a4e5 100644 --- a/blocks/icon/icon.js +++ b/blocks/icon/icon.js @@ -3,6 +3,8 @@ import ComponentBase from '../../scripts/component-base.js'; export default class Icon extends ComponentBase { static observedAttributes = ['icon']; + nestedComponentsConfig = {}; + constructor() { super(); this.setupSprite(); @@ -18,7 +20,6 @@ export default class Icon extends ComponentBase { } get iconUrl() { - // keep as it is return `assets/icons/${this.iconName}.svg`; } diff --git a/blocks/image/image.css b/blocks/image/image.css new file mode 100644 index 00000000..e34c3df2 --- /dev/null +++ b/blocks/image/image.css @@ -0,0 +1,3 @@ +raqn-image a { + display: block; +} \ No newline at end of file diff --git a/blocks/image/image.js b/blocks/image/image.js new file mode 100644 index 00000000..24a5f0ca --- /dev/null +++ b/blocks/image/image.js @@ -0,0 +1,43 @@ +import ComponentBase from '../../scripts/component-base.js'; + +// Not supported as a block +export default class Image extends ComponentBase { + static loaderConfig = { + ...ComponentBase.loaderConfig, + targetsSelectors: 'p:has(>picture:only-child) + p:has(> em:only-child > a:only-child)', + selectorTest: (el) => [el.childNodes.length, el.childNodes[0].childNodes.length].every(len => len === 1), + targetsAsContainers: true, + }; + + nestedComponentsConfig = {}; + + extendConfig() { + return [ + ...super.extendConfig(), + { + addToTargetMethod: 'append', + targetsAsContainers: { + addToTargetMethod: 'append', + }, + }, + ]; + } + + connected() { + this.createLinkedImage(); + } + + createLinkedImage() { + if (!this.children) return; + const em = this.firstElementChild; + const anchor = em.firstElementChild; + const pictureParent = this.parentElement.previousElementSibling; + const picture = pictureParent.firstElementChild; + anchor.setAttribute('aria-label', anchor.textContent); + anchor.innerHTML = ''; + anchor.append(picture); + this.append(anchor); + em.remove(); + pictureParent.remove(); + } +} diff --git a/blocks/navigation/navigation.js b/blocks/navigation/navigation.js index 56edfb06..6dbf2bf9 100644 --- a/blocks/navigation/navigation.js +++ b/blocks/navigation/navigation.js @@ -1,9 +1,36 @@ -import { start } from '../../scripts/init.js'; +import component from '../../scripts/init.js'; import ComponentBase from '../../scripts/component-base.js'; export default class Navigation extends ComponentBase { static observedAttributes = ['icon', 'compact']; + static loaderConfig = { + ...ComponentBase.loaderConfig, + targetsSelectors: ':scope > :is(:first-child)', + }; + + // extendConfig() { + // return [ + // ...super.extendConfig(), + // { + // targetsAsContainers: { + // addToTargetMethod: 'append', + // }, + // }, + // ]; + // } + + /* nestedComponentsConfig = { + columns: { + componentName: 'columns', + // targets: [this], + active: false, + loaderConfig: { + targetsAsContainers: false, + }, + }, + }; */ + attributesValues = { compact: { xs: 'true', @@ -29,7 +56,7 @@ export default class Navigation extends ComponentBase { return this.navButton; } - ready() { + async ready() { this.active = {}; this.navContent = this.querySelector('ul'); this.innerHTML = ''; @@ -45,7 +72,7 @@ export default class Navigation extends ComponentBase { this.isCompact = this.getAttribute('compact') === 'true'; if (this.isCompact) { - this.setupCompactedNav(); + await this.setupCompactedNav(); } else { this.setupNav(); } @@ -60,10 +87,11 @@ export default class Navigation extends ComponentBase { this.nav.append(this.navContent); } - setupCompactedNav() { + async setupCompactedNav() { if (!this.navCompactedContentInit) { this.navCompactedContentInit = true; - start({ name: 'accordion' }); + await Promise.all([component.init({ componentName: 'accordion' }), component.init({ componentName: 'icon' })]); + this.setupClasses(this.navCompactedContent, true); this.navCompactedContent.addEventListener('click', (e) => this.activate(e)); } @@ -81,7 +109,7 @@ export default class Navigation extends ComponentBase { this.setupCompactedNav(); } else { this.classList.remove('active'); - this.navButton.removeAttribute('aria-expanded'); + this.navButton?.removeAttribute('aria-expanded'); this.setupNav(); } } @@ -93,10 +121,18 @@ export default class Navigation extends ComponentBase { } createAccordion(replaceChildrenElement) { - const accordion = document.createElement('raqn-accordion'); - const children = Array.from(replaceChildrenElement.children); - accordion.append(...children); - replaceChildrenElement.append(accordion); + component.init({ + componentName: 'accordion', + targets: [replaceChildrenElement], + config: { + addToTargetMethod: 'append', + }, + }); + + // const accordion = document.createElement('raqn-accordion'); + // const children = Array.from(replaceChildrenElement.children); + // accordion.append(...children); + // replaceChildrenElement.append(accordion); } setupClasses(ul, isCompact, level = 1) { diff --git a/blocks/section-metadata/section-metadata.js b/blocks/section-metadata/section-metadata.js index a3066de9..76675076 100644 --- a/blocks/section-metadata/section-metadata.js +++ b/blocks/section-metadata/section-metadata.js @@ -2,6 +2,8 @@ import { collectAttributes } from '../../scripts/libs.js'; import ComponentBase from '../../scripts/component-base.js'; import ComponentMixin from '../../scripts/component-mixin.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')] diff --git a/blocks/theme/theme.css b/blocks/theming/theming.css similarity index 55% rename from blocks/theme/theme.css rename to blocks/theming/theming.css index bad16572..2b4e4e1d 100644 --- a/blocks/theme/theme.css +++ b/blocks/theming/theming.css @@ -1,3 +1,3 @@ -raqn-theme { +raqn-theming { display: none; } diff --git a/blocks/theme/theme.js b/blocks/theming/theming.js similarity index 91% rename from blocks/theme/theme.js rename to blocks/theming/theming.js index 6d7c59fe..dc2fcd4f 100644 --- a/blocks/theme/theme.js +++ b/blocks/theming/theming.js @@ -1,14 +1,19 @@ import ComponentBase from '../../scripts/component-base.js'; -import { config, getMeta } from '../../scripts/libs.js'; +import { globalConfig, getMeta } from '../../scripts/libs.js'; // minify alias +const metaTheming = getMeta('theming'); +const metaFragment = metaTheming && `${metaTheming}.json`; const k = Object.keys; -export default class Theme extends ComponentBase { +export default class Theming extends ComponentBase { + + nestedComponentsConfig = {}; + constructor() { super(); this.scapeDiv = document.createElement('div'); // keep as it is - this.fragment = 'theme.json'; + this.fragment = metaFragment || 'theming.json'; this.skip = ['tags']; this.toTags = [ 'font-size', @@ -30,7 +35,7 @@ export default class Theme extends ComponentBase { const params = rest.pop().split('.'); const format = params.pop(); const lastBit = params.pop(); - const fontWeight = config.fontWeights[lastBit] || 'regular'; + const fontWeight = globalConfig.fontWeights[lastBit] || 'regular'; const fontStyle = lastBit === 'italic' ? lastBit : 'normal'; // eslint-disable-next-line max-len return `@font-face {font-family: ${name};font-weight: ${fontWeight};font-display: swap;font-style: ${fontStyle};src: url('/fonts/${fontFace}') format(${format});}`; diff --git a/head.html b/head.html index 78d2fe4d..e4f50f0c 100644 --- a/head.html +++ b/head.html @@ -1,18 +1,26 @@ + - - @@ -20,4 +28,4 @@ - + diff --git a/scripts/component-base.js b/scripts/component-base.js index ddc79f10..c7c4191a 100644 --- a/scripts/component-base.js +++ b/scripts/component-base.js @@ -1,5 +1,6 @@ -import { start, startBlock } from './init.js'; -import { getBreakPoints, listenBreakpointChange, camelCaseAttr, capitalizeCaseAttr } from './libs.js'; +import component from './init.js'; + +import { getBreakPoints, listenBreakpointChange, camelCaseAttr, capitalizeCaseAttr, deepMerge } from './libs.js'; export default class ComponentBase extends HTMLElement { static get knownAttributes() { @@ -8,17 +9,65 @@ export default class ComponentBase extends HTMLElement { constructor() { super(); - this.blockName = null; // set by component loader + this.componentName = null; // set by component loader this.webComponentName = null; // set by component loader this.fragment = false; - this.dependencies = []; this.breakpoints = getBreakPoints(); this.uuid = `gen${crypto.randomUUID().split('-')[0]}`; this.attributesValues = {}; // the values are set by the component loader - this.config = {}; + this.nestedComponents = []; + this.setConfig(); this.setBinds(); } + static loaderConfig = { + targetsSelectorsPrefix: null, + targetsSelectors: null, + selectorTest: null, // a function to filter elements matched by targetsSelectors + targetsSelectorsLimit: null, + targetsAsContainers: false, + }; + + static async earlyStopRender() { + return false; + } + + attributesValues = {}; // the values are set by the component loader + + config = { + addToTargetMethod: 'replaceWith', + contentFromTargets: true, + targetsAsContainers: { + addToTargetMethod: 'replaceWith', + }, + }; + + nestedComponentsConfig = { + image: { + componentName: 'image', + }, + button: { + componentName: 'button', + }, + columns: { + componentName: 'columns', + active: false, + loaderConfig: { + targetsAsContainers: false, + }, + }, + }; + + setConfig() { + const configs = this.extendConfig(); + if (!configs.length) return; + this.config = deepMerge({}, ...configs); + } + + extendConfig() { + return [...(super.extendConfig?.() || []), this.config]; + } + setBinds() { this.onBreakpointChange = this.onBreakpointChange.bind(this); } @@ -29,6 +78,7 @@ export default class ComponentBase extends HTMLElement { } } + // TODO change to dataset attributes setBreakpointAttributesValues(e) { Object.entries(this.attributesValues).forEach(([attribute, breakpointsValues]) => { const isAttribute = attribute !== 'class'; @@ -83,33 +133,50 @@ export default class ComponentBase extends HTMLElement { this.initSubscriptions(); // must subscribe each time the element is added to the document if (!this.initialized) { this.setAttribute('id', this.uuid); - if (this.fragment) { - await this.loadFragment(this.fragment); - } - if (this.dependencies.length > 0) { - await Promise.all(this.dependencies.map((dep) => start({ name: dep }))); - } - this.connected(); // manipulate the html + await this.loadFragment(this.fragment); + await this.connected(); // manipulate/create the html + await this.initNestedComponents(); this.addListeners(); // html is ready add listeners - this.ready(); // add extra functionality + await this.ready(); // add extra functionality this.setAttribute('initialized', true); this.initialized = true; - this.dispatchEvent(new CustomEvent('initialized', { detail: { block: this } })); + this.dispatchEvent(new CustomEvent('initialized', { detail: { element: this } })); } } + async initNestedComponents() { + const nested = await Promise.all( + Object.values(this.nestedComponentsConfig).flatMap(async (setting) => { + if (!setting.active) return []; + const s = this.fragment + ? { + // Content can contain blocks which are going to init their own nestedComponents. + targetsSelectorsPrefix: ':scope > div >', // Limit only to default content, exclude blocks. + ...setting, + } + : setting; + return component.init(s); + }), + ); + this.nestedElements = nested.flat(); + } + async loadFragment(path) { - const response = await fetch(`${path}`, window.location.pathname.endsWith(path) ? { cache: 'reload' } : {}); - return this.processFragment(response); + if (!path) return; + const response = await this.getFragment(path); + await this.processFragment(response); + } + + getFragment(path) { + return fetch(`${path}`, window.location.pathname.endsWith(path) ? { cache: 'reload' } : {}); } async processFragment(response) { if (response.ok) { const html = await response.text(); this.innerHTML = html; - return this.querySelectorAll(':scope > div > div').forEach((block) => startBlock(block)); + await Promise.all([...this.querySelectorAll('div[class]')].map((block) => component.init({ targets: [block] }))); } - return response; } initSubscriptions() {} diff --git a/scripts/component-loader.js b/scripts/component-loader.js index 097c64fd..8ad0029b 100644 --- a/scripts/component-loader.js +++ b/scripts/component-loader.js @@ -1,91 +1,207 @@ -import { config, collectAttributes, loadModule } from './libs.js'; +import { collectAttributes, loadModule, deepMerge, mergeUniqueArrays } from './libs.js'; +// import ComponentBase from './component-base.js'; + import ComponentMixin from './component-mixin.js'; export default class ComponentLoader { - constructor(blockName, element) { - window.raqnComponents = window.raqnComponents || {}; - this.blockName = blockName; - this.pathWithoutExtension = `/blocks/${this.blockName}/${this.blockName}`; - this.block = element; - if (this.block) { - this.content = this.block.children; + constructor({ componentName, targets = [], loaderConfig, rawClasses, config, active: isNested }) { + window.raqnComponents ??= {}; + if (!componentName) { + // eslint-disable-next-line no-console + console.error('`componentName` is required'); + return; } + this.componentName = componentName; + this.targets = targets.map((target) => ({ target })); + this.loaderConfig = loaderConfig; + this.rawClasses = rawClasses?.trim?.().split?.(' ') || []; + this.config = config; + this.pathWithoutExtension = `/blocks/${this.componentName}/${this.componentName}`; + this.isWebComponent = null; + this.isClass = null; + this.isFn = null; + this.isNested = isNested; } - get handler() { - return window.raqnComponents[this.blockName]; + get Handler() { + return window.raqnComponents[this.componentName]; } - set handler(handler) { - window.raqnComponents[this.blockName] = handler; + set Handler(handler) { + window.raqnComponents[this.componentName] = handler; } - isWebComponentClass(clazz = this.handler) { - return clazz.toString().startsWith('class'); + setHandlerType(handler = this.Handler) { + this.isWebComponent = handler.prototype instanceof HTMLElement; + this.isClass = !this.isWebComponent && handler.toString().startsWith('class'); + this.isFn = !this.isWebComponent && !this.isClass && typeof handler === 'function'; } get webComponentName() { - return `raqn-${this.blockName.toLowerCase()}`; + return `raqn-${this.componentName.toLowerCase()}`; + } + + async init() { + if (!this.componentName) return null; + const loaded = await this.loadAndDefine(); + if (!loaded) return null; + this.setHandlerType(); + if (await this.Handler?.earlyStopRender?.()) return this.Handler; + if (!this.targets?.length) return this.Handler; + + this.setTargets(); + return Promise.all( + this.targets.map(async (target) => { + const data = this.getTargetData(target); + if (this.isWebComponent) { + const elem = await this.createElementAndConfigure(data); + data.componentElem = elem; + this.addContentFromTarget(data); + await this.connectComponent(data); + return elem; + } + + if (this.isClass) { + return new this.Handler({ + componentName: this.componentName, + ...data, + }); + } + + if (this.isFn) { + return this.Handler(data); + } + return null; + }), + ); + } + + getTargetData({ target, container }) { + return { + target, + container, + rawClasses: !container ? mergeUniqueArrays(this.rawClasses, target.classList) : this.rawClasses, + // content: target?.childNodes, + }; + } + + setTargets() { + this.loaderConfig = deepMerge({}, this.Handler.loaderConfig, this.loaderConfig); + const { targetsSelectorsPrefix, targetsSelectors, targetsSelectorsLimit, targetsAsContainers, selectorTest } = + this.loaderConfig; + const selector = `${targetsSelectorsPrefix || ''} ${targetsSelectors}`; + if (targetsAsContainers) { + this.targets = this.targets.flatMap(({ target: container }) => { + const targetsFromContainer = this.getTargets(container, selector, selectorTest, targetsSelectorsLimit); + return targetsFromContainer.map((target) => ({ + target, + container, + })); + }); + } + } + + getTargets(container, selector, selectorTest, length = 1) { + const queryType = length && length <= 1 ? 'querySelector' : 'querySelectorAll'; + let elements = container[queryType](selector); + + if (length === null) elements = [...elements]; + if (length > 1) elements = [...elements].slice(0, length); + if (length === 1) elements = [elements]; + + if (typeof selectorTest === 'function') { + elements = elements.filter((el) => selectorTest(el)); + } + + return elements; } - async setupElement() { - const element = document.createElement(this.webComponentName); - element.blockName = this.blockName; - element.webComponentName = this.webComponentName; - element.append(...this.block.children); - const { currentAttributes } = collectAttributes( - this.blockName, - this.block.classList, + 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, await ComponentMixin.getMixins(), - this?.handler?.knownAttributes, - element, + this?.Handler?.knownAttributes, + componentElem, ); + Object.keys(currentAttributes).forEach((key) => { - element.setAttribute(key, currentAttributes[key]); + componentElem.setAttribute(key, currentAttributes[key]); + }); + + componentElem.nestedComponentsConfig = deepMerge(nestedComponentsConfig, nestedComponents); + + Object.keys(nestedComponentsConfig).forEach((key) => { + const defaults = { + targets: [componentElem], + active: true, + loaderConfig: { + targetsAsContainers: true, + }, + }; + nestedComponentsConfig[key] = deepMerge(defaults, nestedComponentsConfig[key]); }); + return componentElem; + } + + addContentFromTarget(data) { + const { componentElem, target } = data; + const { contentFromTargets } = componentElem.config; + if (!contentFromTargets) return; + + componentElem.append(...target.children); + } + + async connectComponent(data) { + const { componentElem } = data; + componentElem.setAttribute('isloading', ''); const initialized = new Promise((resolve) => { const initListener = async (event) => { - if (event.detail.block === element) { - element.removeEventListener('initialized', initListener); - await ComponentMixin.startAll(element); - resolve(); + if (event.detail.element === componentElem) { + componentElem.removeEventListener('initialized', initListener); + await ComponentMixin.startAll(componentElem); + componentElem.removeAttribute('isloading'); + resolve(componentElem); } }; - element.addEventListener('initialized', initListener); + componentElem.addEventListener('initialized', initListener); }); - const isSemanticElement = config.semanticBlocks.includes(this.block.tagName.toLowerCase()); - const addComponentMethod = isSemanticElement ? 'append' : 'replaceWith'; - this.block[addComponentMethod](element); - await initialized; + const { targetsAsContainers } = this.loaderConfig; + const conf = componentElem.config; + const addToTargetMethod = targetsAsContainers ? conf.targetsAsContainers.addToTargetMethod : conf.addToTargetMethod; + data.target[addToTargetMethod](componentElem); + + return initialized; } - async start() { + async loadAndDefine() { try { let cssLoaded = Promise.resolve(); - if (!this.handler) { - this.handler = (async () => { + if (!this.Handler) { + this.Handler = (async () => { const { css, js } = loadModule(this.pathWithoutExtension); cssLoaded = css; const mod = await js; - if (this.isWebComponentClass(mod.default)) { + if (mod.default.prototype instanceof HTMLElement) { window.customElements.define(this.webComponentName, mod.default); } return mod.default; })(); } - this.handler = await this.handler; - if (this.block) { - if (this.isWebComponentClass()) { - await this.setupElement(); - } else { - await this.handler(this.block); - } - } + this.Handler = await this.Handler; await cssLoaded; + return true; } catch (error) { // eslint-disable-next-line no-console - console.error(`failed to load module for ${this.blockName}`, error); + console.error(`failed to load module for ${this.componentName}`, error); + return false; } } } diff --git a/scripts/init.js b/scripts/init.js index 027ea915..92589464 100644 --- a/scripts/init.js +++ b/scripts/init.js @@ -1,84 +1,156 @@ import ComponentLoader from './component-loader.js'; import ComponentMixin from './component-mixin.js'; -import { - config, - eagerImage, - getMeta, -} from './libs.js'; - -function getInfo(block) { - const el = block; - const tagName = el.tagName.toLowerCase(); - let name = tagName; - if (!config.semanticBlocks.includes(tagName)) { - [name] = Array.from(el.classList); - } - return { - name, - el, - }; -} - -function getInfos(blocks) { - return blocks.map((block) => getInfo(block)); -} - -export async function start({ name, el }) { - const loader = new ComponentLoader(name, el); - return loader.start(); -} - -export async function startBlock(block) { - return start(getInfo(block)); -} - -function initEagerImages() { - const eagerImages = getMeta('eager-images'); - if (eagerImages) { - const length = parseInt(eagerImages, 10); - eagerImage(document.body, length); - } -} - -function getLcp() { - const lcpMeta = getMeta('lcp'); - return lcpMeta - ? lcpMeta.split(',').map((name) => ({ name: name.trim() })) - : []; -} - -function includesInfo(infos, search) { - return infos.find(({ name }) => name === search); -} - -async function init() { - ComponentMixin.getMixins(); - - // mechanism of retrieving lang to be used in the app - // TODO - set this based on url structure or meta tag for current path - document.documentElement.lang ||= 'en'; - - initEagerImages(); - - const blocks = [ - document.body.querySelector(config.semanticBlocks[0]), - ...document.querySelectorAll('[class]:not([class^=style]'), - ...document.body.querySelectorAll(config.semanticBlocks.slice(1).join(',')), - ]; - - const data = getInfos(blocks); - const lcp = getLcp().map(({ name }) => includesInfo(data, name) || { name }); - const delay = window.raqnLCPDelay || []; - const lazy = data.filter( - ({ name }) => !includesInfo(lcp, name) && !includesInfo(delay, name), - ); - - // start with lcp - Promise.all(lcp.map(({ name, el }) => start({ name, el }))).then(() => { - document.body.style.display = 'unset'; - }); - // timeout for the rest to proper prioritize in case of stalled loading - lazy.map(({ name, el }) => setTimeout(() => start({ name, el }))); -} - -init(); +import { globalConfig, eagerImage, getMeta, getMetaGroup } from './libs.js'; + +const component = { + async init(settings) { + return new ComponentLoader({ + ...settings, + componentName: settings.componentName ?? this.getBlockData(settings?.targets?.[0]).componentName, + }).init(); + }, + + async loadAndDefine(componentName) { + await new ComponentLoader({ componentName }).loadAndDefine(); + }, + + getBlockData(block) { + const tagName = block.tagName.toLowerCase(); + const lcp = block.classList.contains('lcp'); + let componentName = tagName; + if (!globalConfig.semanticBlocks.includes(tagName)) { + componentName = block.classList.item(0); + } + return { block, componentName, lcp }; + }, +}; + +const onLoadComponents = { + staticStructureComponents: [ + { + componentName: 'image', + block: document, + loaderConfig: { + targetsAsContainers: true, + targetsSelectorsPrefix: 'main > div >', + }, + }, + { + componentName: 'button', + block: 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('[class]:not([class^=style]'), + ...document.body.querySelectorAll(globalConfig.semanticBlocks.slice(1).join(',')), + ]; + }, + + setBlocksData() { + const structureData = this.structureComponents.map(({ componentName }) => ({ + componentName, + block: document, + loaderConfig: { + targetsAsContainers: true, + }, + })); + structureData.push(...this.staticStructureComponents); + + const blocksData = this.blocks.map((block) => component.getBlockData(block)); + this.blocksData = [...structureData, ...blocksData]; + }, + + setLcp() { + const lcpMeta = getMeta('lcp'); + const defaultLcp = ['theme', 'header', 'breadcrumbs']; + this.lcp = lcpMeta?.length + ? lcpMeta.split(',').map((componentName) => ({ componentName: componentName.trim() })) + : defaultLcp; + }, + + setStructure() { + const structureComponents = getMetaGroup('structure'); + this.structureComponents = structureComponents.flatMap(({ name, content }) => { + if (content !== true) return []; + return { + componentName: name.trim(), + }; + }); + }, + + setLcpBlocks() { + this.lcpBlocks = this.blocksData.filter((data) => !!this.findLcp(data)); + }, + + setLazyBlocks() { + this.lazyBlocks = this.blocksData.filter((data) => !this.findLcp(data)); + }, + + findLcp(data) { + return ( + this.lcp.find(({ componentName }) => componentName === data.componentName) || + data.lcp /* || + [...document.querySelectorAll('main > div > [class]:nth-child(-n+2)')].find((el) => el === data.block) */ + ); + }, + + initBlocks() { + Promise.all( + this.lcpBlocks.map(async ({ componentName, block, loaderConfig }) => + component.init({ componentName, targets: [block], loaderConfig }), + ), + ).then(() => { + document.body.style.display = 'unset'; + }); + this.lazyBlocks.map(({ componentName, block, loaderConfig }) => + setTimeout(() => component.init({ componentName, targets: [block], loaderConfig })), + ); + }, +}; + +const globalInit = { + async init() { + this.loadMixins(); + this.setLang(); + this.initEagerImages(); + onLoadComponents.init(); + }, + + loadMixins() { + ComponentMixin.getMixins(); + }, + + // TODO - maybe take this from the url structure. + setLang() { + document.documentElement.lang ||= 'en'; + }, + + initEagerImages() { + const eagerImages = getMeta('eager-images'); + if (eagerImages) { + const length = parseInt(eagerImages, 10); + eagerImage(document.body, length); + } + }, +}; + +globalInit.init(); + +export default component; diff --git a/scripts/libs.js b/scripts/libs.js index 531ccbd9..7d30580e 100644 --- a/scripts/libs.js +++ b/scripts/libs.js @@ -1,4 +1,4 @@ -export const config = { +export const globalConfig = { semanticBlocks: ['header', 'footer'], breakpoints: { xs: 0, @@ -35,7 +35,7 @@ export function getBreakPoints() { // return if already set if (window.raqnBreakpoints.ordered.length) return window.raqnBreakpoints; - window.raqnBreakpoints.ordered = Object.entries(config.breakpoints) + window.raqnBreakpoints.ordered = Object.entries(globalConfig.breakpoints) .sort((a, b) => a[1] - b[1]) .map(([breakpointMinName, breakpointMin], index, arr) => { const [, breakpointNext] = arr[index + 1] || []; @@ -60,9 +60,9 @@ export function getBreakPoints() { export function listenBreakpointChange(callback) { const breakpoints = getBreakPoints(); let { active } = breakpoints; - + const listeners = []; breakpoints.ordered.forEach((breakpoint) => { - breakpoint.matchMedia.addEventListener('change', (e) => { + const fn = (e) => { e.raqnBreakpoint = { ...breakpoint }; if (e.matches) { @@ -74,8 +74,16 @@ export function listenBreakpointChange(callback) { } callback?.(e); - }); + }; + listeners.push({ media: breakpoint.matchMedia, callback: fn }); + breakpoint.matchMedia.addEventListener('change', fn); }); + + return { + removeBreakpointListeners: () => { + listeners.forEach((listener) => listener.media.removeEventListener('change', listener.callback)); + }, + }; } export const debounce = (func, wait, immediate) => { @@ -107,34 +115,88 @@ export const eagerImage = (block, length = 1) => { }); }; +export function stringToJsVal(string) { + switch (string.trim()) { + case 'true': + return true; + case 'false': + return false; + case 'null': + return null; + case 'undefined': + return undefined; + default: + return string; + } +} + export function getMeta(name) { const meta = document.querySelector(`meta[name="${name}"]`); if (!meta) { return null; } - return meta.content; + return stringToJsVal(meta.content); } -export function collectAttributes(blockName, classes, mixins, knownAttributes = [], element = null) { +export function getMetaGroup(group) { + const prefix = `${group}-`; + const metaGroup = [...document.querySelectorAll(`meta[name^="${prefix}"]`)]; + return metaGroup.map((meta) => ({ + name: meta.name.replace(new RegExp(`^${prefix}`), ''), + content: stringToJsVal(meta.content), + })); +} + +export function collectAttributes(componentName, classes, mixins, knownAttributes = [], element = null) { + const classesList = []; const mediaAttributes = {}; - // inherit default param values 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); const mixinKnownAttributes = mixins.flatMap((mixin) => mixin.observedAttributes || []); - const attrs = Array.from(classes) - .filter((c) => c !== blockName && c !== 'block') + const attrs = classesList + .filter((c) => c !== componentName && c !== 'block') .reduce((acc, c) => { let value = c; let isKnownAttribute = null; let isMixinKnownAttributes = null; - const classBreakpoint = Object.keys(config.breakpoints).find((b) => c.startsWith(`${b}-`)); + const classBreakpoint = Object.keys(globalConfig.breakpoints).find((b) => c.startsWith(`${b}-`)); const activeBreakpoint = getBreakPoints().active.name; if (classBreakpoint) { value = value.slice(classBreakpoint.length + 1); } + 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; + } + let key = 'class'; const isClassValue = value.startsWith(key); if (isClassValue) { @@ -160,11 +222,12 @@ export function collectAttributes(blockName, classes, mixins, knownAttributes = } if (isKnownAttribute) attributesValues[camelCaseKey][classBreakpoint] = value; if (isClass) { - if (attributesValues[camelCaseKey][classBreakpoint]) { - attributesValues[camelCaseKey][classBreakpoint] += ` ${value}`; - } else { - attributesValues[camelCaseKey][classBreakpoint] = value; - } + attributesValues[camelCaseKey][classBreakpoint] ??= ''; + // if (attributesValues[camelCaseKey][classBreakpoint]) { + attributesValues[camelCaseKey][classBreakpoint] += `${value} `; + // } else { + // attributesValues[camelCaseKey][classBreakpoint] = value; + // } } // support multivalue attributes } else if (acc[key]) { @@ -173,13 +236,12 @@ export function collectAttributes(blockName, classes, mixins, knownAttributes = acc[key] = value; } - if (isKnownAttribute || isClass) attributesValues[camelCaseKey].all = acc[key]; + if ((isKnownAttribute || isClass) && acc[key]) attributesValues[camelCaseKey].all = acc[key]; return acc; }, {}); return { - // TODO improve how classes are collected and merged. currentAttributes: { ...attrs, ...mediaAttributes, @@ -188,6 +250,7 @@ export function collectAttributes(blockName, classes, mixins, knownAttributes = }), }, attributesValues, + nestedComponents, }; } @@ -212,3 +275,42 @@ export function loadModule(urlWithoutExtension) { return { css, js }; } + +export function mergeUniqueArrays(...arrays) { + const mergedArrays = arrays.reduce((acc, arr) => [...acc, ...(arr || [])], []); + return [...new Set(mergedArrays)]; +} + +export function getBaseUrl() { + return document.head.querySelector('base').href; +} + +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); +} diff --git a/styles/styles.css b/styles/styles.css index 4bb0a9b2..ddbcea56 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -62,18 +62,18 @@ caption { color: currentcolor; } -/* header { +header { --scope-background: var(--scope-header-background, #fff); --scope-color: var(--scope-header-color, #000); min-height: var(--scope-header-height, 64px); display: grid; background: var(--scope-header-background, #fff); -} */ +} -main { +/* main { margin-top: var(--scope-header-height, 64px); -} +} */ main > div { max-width: var(--scope-max-width, 100%); @@ -154,6 +154,8 @@ img { height: auto; } + +[isloading], .hide { display: none; pointer-events: none;