From d6d375c9d12fcbdc640be36c28056ccf1f26710a Mon Sep 17 00:00:00 2001 From: Florin Raducan Date: Wed, 27 Mar 2024 10:41:30 +0200 Subject: [PATCH 1/2] 456187 - Handle attributes values on breakpoint changes --- .eslintrc.js | 3 + 404.html | 23 +- blocks/card/card.js | 23 +- blocks/header/header.css | 2 + blocks/hero/hero.js | 5 +- blocks/icon/icon.js | 4 +- blocks/navigation/navigation.js | 100 ++++++--- blocks/section-metadata/section-metadata.js | 10 +- blocks/theme/theme.js | 3 +- docs/raqn/components.md | 3 +- head.html | 50 ++--- mixins/column/column.js | 4 +- scripts/component-base.js | 98 ++++++++- scripts/component-loader.js | 23 +- scripts/init.js | 25 +-- scripts/libs.js | 219 +++++++++++++++----- scripts/pubsub.js | 128 ++++++++++++ styles/styles.css | 4 +- 18 files changed, 555 insertions(+), 172 deletions(-) create mode 100644 scripts/pubsub.js diff --git a/.eslintrc.js b/.eslintrc.js index 62ea182f..c8ea4202 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,6 +19,9 @@ module.exports = { // allow reassigning param 'no-param-reassign': [2, { props: false }], 'linebreak-style': ['error', 'unix'], + semi: [2, 'always'], + quotes: [2, 'single', { avoidEscape: true }], + 'no-multiple-empty-lines': ['error', { max: 1, maxEOF: 0 }], 'import/extensions': [ 'error', { diff --git a/404.html b/404.html index c21fc249..26c5fff5 100644 --- a/404.html +++ b/404.html @@ -9,26 +9,6 @@ - - diff --git a/blocks/card/card.js b/blocks/card/card.js index 8ade09dc..8019a96e 100644 --- a/blocks/card/card.js +++ b/blocks/card/card.js @@ -11,10 +11,7 @@ export default class Card extends ComponentBase { ); } this.eager = parseInt(this.getAttribute('eager') || 0, 10); - this.ratio = this.getAttribute('ratio') || '4/3'; - this.style.setProperty('--card-ratio', this.ratio); this.classList.add('inner'); - this.setupColumns(this.getAttribute('columns')); if (this.eager) { eagerImage(this, this.eager); } @@ -27,14 +24,28 @@ export default class Card extends ComponentBase { a.replaceWith(button); } + setupRatio(ratio) { + this.ratio = ratio || '4/3'; + this.style.setProperty('--card-ratio', this.ratio); + } + setupColumns(columns) { - if (!columns) { - return; - } + if (!columns) return; + this.columns = parseInt(columns, 10); this.area = Array.from(Array(parseInt(this.columns, 10))) .map(() => '1fr') .join(' '); this.style.setProperty('--card-columns', this.area); } + + onAttrChanged_columns({ oldValue, newValue }) { + if (oldValue === newValue) return; + this.setupColumns(newValue); + } + + onAttrChanged_ratio({ oldValue, newValue }) { + if (oldValue === newValue) return; + this.setupRatio(newValue); + } } diff --git a/blocks/header/header.css b/blocks/header/header.css index 689a4775..0f9948f8 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -1,8 +1,10 @@ raqn-header { --scope-background: var(--scope-header-background, #fff); --scope-color: var(--scope-header-color, #000); + --scope-top: var(--scope-header-top, 0); position: fixed; + top: var(--scope-top); width: 100%; min-height: var(--scope-header-height, 64px); display: grid; diff --git a/blocks/hero/hero.js b/blocks/hero/hero.js index 3be97b05..a9b01bea 100644 --- a/blocks/hero/hero.js +++ b/blocks/hero/hero.js @@ -9,6 +9,9 @@ export default class Hero extends ComponentBase { this.classList.add('full-width'); this.setAttribute('role', 'banner'); this.setAttribute('aria-label', 'hero'); - this.style.setProperty('--hero-hero-order', this.getAttribute('order')); + } + + onAttrChanged_order({ newValue }) { + this.style.setProperty('--hero-hero-order', newValue); } } diff --git a/blocks/icon/icon.js b/blocks/icon/icon.js index 27638ec7..0db67d2a 100644 --- a/blocks/icon/icon.js +++ b/blocks/icon/icon.js @@ -28,6 +28,8 @@ export default class Icon extends ComponentBase { } async connected() { + this.setAttribute('aria-hidden', 'true'); + this.iconName = this.getAttribute('icon'); if (!this.cache[this.iconName]) { this.cache[this.iconName] = { @@ -52,7 +54,7 @@ export default class Icon extends ComponentBase { return ''; }) .join(' '); - return ``; + return ``; } iconTemplate(iconName, svg, viewBox, width, height) { diff --git a/blocks/navigation/navigation.js b/blocks/navigation/navigation.js index 367f2b15..1a628676 100644 --- a/blocks/navigation/navigation.js +++ b/blocks/navigation/navigation.js @@ -4,38 +4,85 @@ import ComponentBase from '../../scripts/component-base.js'; export default class Navigation extends ComponentBase { static observedAttributes = ['icon', 'compact']; + attributesValues = { + compact: { + xs: 'true', + s: 'true', + m: 'true', + all: 'false', + }, + }; + createButton() { - const button = document.createElement('button'); - button.setAttribute('aria-label', 'Menu'); - button.setAttribute('aria-expanded', 'false'); - button.setAttribute('aria-controls', 'navigation'); - button.setAttribute('aria-haspopup', 'true'); - button.setAttribute('type', 'button'); - button.setAttribute('tabindex', '0'); - button.innerHTML = ``; - button.addEventListener('click', () => { + 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.setAttribute('tabindex', '0'); + this.navButton.innerHTML = ''; + this.navButton.addEventListener('click', () => { this.classList.toggle('active'); - button.setAttribute('aria-expanded', this.classList.contains('active')); + this.navButton.setAttribute('aria-expanded', this.classList.contains('active')); }); - return button; + return this.navButton; } ready() { this.active = {}; - this.list = this.querySelector('ul'); + this.navContent = this.querySelector('ul'); + this.innerHTML = ''; + this.navContentInit = false; + this.navCompactedContent = this.navContent.cloneNode(true); // the clone need to be done before `this.navContent` is modified + this.navCompactedContentInit = false; this.nav = document.createElement('nav'); - this.nav.append(this.list); + this.append(this.nav); this.setAttribute('role', 'navigation'); - this.compact = this.getAttribute('compact') === 'true' || false; + this.icon = this.getAttribute('icon') || 'menu'; - if (this.compact) { - this.nav.append(this.createButton()); - start({ name: 'accordion' }); + + this.isCompact = this.getAttribute('compact') === 'true'; + + if (this.isCompact) { + this.setupCompactedNav(); + } else { + this.setupNav(); } - this.append(this.nav); - this.setupClasses(this.list); - if (this.compact) { - this.addEventListener('click', (e) => this.activate(e)); + } + + setupNav() { + if (!this.navContentInit) { + this.navContentInit = true; + this.setupClasses(this.navContent); + }; + + this.nav.append(this.navContent); + } + + setupCompactedNav() { + if (!this.navCompactedContentInit) { + this.navCompactedContentInit = true; + start({ name: 'accordion' }); + this.setupClasses(this.navCompactedContent, true); + this.navCompactedContent.addEventListener('click', (e) => this.activate(e)); + }; + + this.nav.append(this.createButton()); + this.nav.append(this.navCompactedContent); + } + + onAttrChanged_compact({ newValue }) { + if (!this.initialized) return; + this.isCompact = newValue === 'true'; + this.nav.innerHTML = ''; + + if (this.isCompact) { + this.setupCompactedNav(); + } else { + this.classList.remove('active'); + this.navButton.removeAttribute('aria-expanded'); + this.setupNav(); } } @@ -45,15 +92,16 @@ export default class Navigation extends ComponentBase { return icon; } - creaeteAccordion(replaceChildrenElement) { + createAccordion(replaceChildrenElement) { const accordion = document.createElement('raqn-accordion'); const children = Array.from(replaceChildrenElement.children); accordion.append(...children); replaceChildrenElement.append(accordion); } - setupClasses(ul, level = 1) { + setupClasses(ul, isCompact, level = 1) { const children = Array.from(ul.children); + children.forEach((child) => { const hasChildren = child.querySelector('ul'); child.classList.add(`level-${level}`); @@ -61,13 +109,13 @@ export default class Navigation extends ComponentBase { if (hasChildren) { const anchor = child.querySelector('a'); - if (this.compact) { - this.creaeteAccordion(child); + if (isCompact) { + this.createAccordion(child); } else if (level === 1) { anchor.append(this.createIcon('chevron-right')); } child.classList.add('has-children'); - this.setupClasses(hasChildren, level + 1); + this.setupClasses(hasChildren, isCompact, level + 1); } }); } diff --git a/blocks/section-metadata/section-metadata.js b/blocks/section-metadata/section-metadata.js index 0db8a7db..a3066de9 100644 --- a/blocks/section-metadata/section-metadata.js +++ b/blocks/section-metadata/section-metadata.js @@ -1,4 +1,4 @@ -import { collectParams } from '../../scripts/libs.js'; +import { collectAttributes } from '../../scripts/libs.js'; import ComponentBase from '../../scripts/component-base.js'; import ComponentMixin from '../../scripts/component-mixin.js'; @@ -7,13 +7,13 @@ export default class SectionMetadata extends ComponentBase { const classes = [...this.querySelectorAll(':scope > div > div:first-child')] .map((keyCell) => `${keyCell.textContent.trim()}-${keyCell.nextElementSibling.textContent.trim()}`); - const params = collectParams('section-metadata', classes, await ComponentMixin.getMixins(), this.knownAttributes); + const { currentAttributes } = collectAttributes('section-metadata', classes, await ComponentMixin.getMixins(), this.knownAttributes, this); const section = this.parentElement; - Object.keys(params).forEach((key) => { + Object.keys(currentAttributes).forEach((key) => { if(key === 'class') { - section.setAttribute(key, params[key]); + section.setAttribute(key, currentAttributes[key]); } else { - section.setAttribute(`data-${key}`, params[key]); + section.setAttribute(`data-${key}`, currentAttributes[key]); } }); await ComponentMixin.startAll(section); diff --git a/blocks/theme/theme.js b/blocks/theme/theme.js index 86fe2602..6d7c59fe 100644 --- a/blocks/theme/theme.js +++ b/blocks/theme/theme.js @@ -38,7 +38,6 @@ export default class Theme extends ComponentBase { return ''; } - fontTags(t, index) { const tag = t.tags[index]; const values = this.toTags.reduce((acc, key) => { @@ -123,7 +122,7 @@ export default class Theme extends ComponentBase { ) .filter((v) => v !== '') .join('')} - }`, + }`, ) .join(''); diff --git a/docs/raqn/components.md b/docs/raqn/components.md index 071ec1d4..ac1e221c 100644 --- a/docs/raqn/components.md +++ b/docs/raqn/components.md @@ -185,7 +185,8 @@ A param is set to all viewports. To set a param only to a specific viewport, prefix it with the viewport key: -1. **s**: 0 to 767, +1. **xs**: 0 to 479, +1. **s**: 480 to 767, 2. **m**: 768 to 1023, 3. **l**: 1024 to 1279, 4. **xl**: 1280 to 1919, diff --git a/head.html b/head.html index 3a7e1b29..78d2fe4d 100644 --- a/head.html +++ b/head.html @@ -1,27 +1,23 @@ - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/mixins/column/column.js b/mixins/column/column.js index 8a99b3d9..909f3dde 100644 --- a/mixins/column/column.js +++ b/mixins/column/column.js @@ -14,13 +14,13 @@ export default class Column extends ComponentMixin { const content = this.element.querySelectorAll('div > div'); // clean up dom structure (div div div div div div) and save the content this.contentChildren = Array.from(content).map((child) => { - const {children} = child; + const { children } = child; const parent = child.parentNode; if (children.length > 0) { child.replaceWith(...children); } return parent; - }) + }); this.calculateGridTemplateColumns(); } diff --git a/scripts/component-base.js b/scripts/component-base.js index 15532abc..f9bacfe0 100644 --- a/scripts/component-base.js +++ b/scripts/component-base.js @@ -1,4 +1,6 @@ import { start, startBlock } from './init.js'; +import { getBreakPoints } from './libs.js'; +import { publish, subscribe, unsubscribe, unsubscribeAll } from './pubsub.js'; export default class ComponentBase extends HTMLElement { static get knownAttributes() { @@ -12,12 +14,99 @@ export default class ComponentBase extends HTMLElement { super(); this.fragment = false; this.dependencies = []; + this.breakpoints = getBreakPoints(); this.uuid = `gen${crypto.randomUUID().split('-')[0]}`; + this.activeSubscriptions = []; // populated by the internal pubSub methods + this.attributesValues = {}; // the values are set by the component loader + this.setBinds(); + } + + config = { + subscriptions: { + breakpointMatches: 'breakpoint::change::matches', + }, + }; + + setBinds() { + this.onBreakpointMatches = this.onBreakpointMatches.bind(this); + } + + // Use only the internal pub-sub methods + subscribe(event, callback, options) { + subscribe(event, callback, { scope: this, ...options }); + } + + publish(event, data, options) { + publish(event, data, options); + } + + unsubscribe(event, callback, options) { + unsubscribe(event, callback, { scope: this, ...options }); + } + + unsubscribeAll(options) { + unsubscribeAll({ scope: this, ...options }); + } + + disconnectedCallback() { + this.unsubscribeAll(); // must unsubscribe each time the element is added to the document + } + + initSubscriptions() { + this.subscribe(this.config.subscriptions.breakpointMatches, this.onBreakpointMatches); + } + + onBreakpointMatches(e) { + this.setBreakpointAttributesValues(e); + } + + setBreakpointAttributesValues(e) { + Object.entries(this.attributesValues).forEach(([attribute, breakpointsValues]) => { + const isAttribute = attribute !== 'class'; + if (isAttribute) { + const newValue = breakpointsValues[e.raqnBreakpoint.name] ?? breakpointsValues.all; + // this will trigger the `attributeChangedCallback` and a `onAttrChanged_${name}` method + // should be defined to handle the attribute value change + if (newValue ?? false) { + if (this.getAttribute(attribute) === newValue) return; + this.setAttribute(attribute, newValue); + } else { + this.removeAttribute(attribute, newValue); + } + } else { + const prevClasses = (breakpointsValues[e.previousRaqnBreakpoint.name] ?? '').split(' ').filter((x)=> x); + const newClasses = (breakpointsValues[e.raqnBreakpoint.name] ?? '').split(' ').filter((x)=> x); + const removeClasses = prevClasses.filter((prevClass) => !newClasses.includes(prevClass)); + const addClasses = newClasses.filter((newClass) => !prevClasses.includes(newClass)); + + if (removeClasses.length) this.classList.remove(...removeClasses); + if (addClasses.length) this.classList.add(...addClasses); + } + }); + } + + /** + * Attributes are assigned before the `connectedCallback` is triggered. + * In some cases a check for `this.initialized` inside `onAttrChanged_${name}` might be required + */ + attributeChangedCallback(name, oldValue, newValue) { + // handle case when attribute is removed from the element + // default to attribute breakpoint value + const defaultNewVal = (newValue === null) ? (this.getBreakpointAttrVal(name) ?? null) : newValue; + this[`onAttrChanged_${name}`]?.({ oldValue, newValue: defaultNewVal }); + } + + getBreakpointAttrVal(attr) { + const { name: activeBrName } = this.breakpoints.activeMinMax; + const attribute = this.attributesValues?.[attr]; + if (!attribute) return undefined; + return attribute?.[activeBrName] ?? attribute?.all; } async connectedCallback() { - const initialized = this.getAttribute('initialized'); - if (!initialized) { + 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); if (this.fragment) { await this.loadFragment(this.fragment); @@ -28,6 +117,7 @@ export default class ComponentBase extends HTMLElement { this.connected(); this.ready(); this.setAttribute('initialized', true); + this.initialized = true; this.dispatchEvent( new CustomEvent('initialized', { detail: { block: this } }), ); @@ -53,7 +143,7 @@ export default class ComponentBase extends HTMLElement { return response; } - connected() {} + connected() { } - ready() {} + ready() { } } diff --git a/scripts/component-loader.js b/scripts/component-loader.js index 3aaeda3d..fec47c00 100644 --- a/scripts/component-loader.js +++ b/scripts/component-loader.js @@ -1,4 +1,4 @@ -import { collectParams, loadModule } from './libs.js'; +import { config, collectAttributes, loadModule } from './libs.js'; import ComponentMixin from './component-mixin.js'; export default class ComponentLoader { @@ -31,11 +31,20 @@ export default class ComponentLoader { async setupElement() { const element = document.createElement(this.webComponentName); + element.blockName = this.blockName; + element.webComponentName = this.webComponentName; element.append(...this.block.children); - const params = collectParams(this.blockName, this.block.classList, await ComponentMixin.getMixins(), this.handler && this.handler.knownAttributes); - Object.keys(params).forEach((key) => { - element.setAttribute(key, params[key]); + const { currentAttributes } = collectAttributes( + this.blockName, + this.block.classList, + await ComponentMixin.getMixins(), + this?.handler?.knownAttributes, + element, + ); + Object.keys(currentAttributes).forEach((key) => { + element.setAttribute(key, currentAttributes[key]); }); + const initialized = new Promise((resolve) => { const initListener = async (event) => { if(event.detail.block === element) { @@ -46,7 +55,9 @@ export default class ComponentLoader { }; element.addEventListener('initialized', initListener); }); - this.block.replaceWith(element); + const isSemanticElement = config.semanticBlocks.includes(this.block.tagName.toLowerCase()); + const addComponentMethod = isSemanticElement ? 'append' : 'replaceWith'; + this.block[addComponentMethod](element); await initialized; } @@ -59,7 +70,7 @@ export default class ComponentLoader { cssLoaded = css; const mod = await js; if(this.isWebComponentClass(mod.default)) { - customElements.define(this.webComponentName, mod.default); + window.customElements.define(this.webComponentName, mod.default); } return mod.default; })(); diff --git a/scripts/init.js b/scripts/init.js index b140eeab..f5fda5ac 100644 --- a/scripts/init.js +++ b/scripts/init.js @@ -2,10 +2,9 @@ import ComponentLoader from './component-loader.js'; import ComponentMixin from './component-mixin.js'; import { config, - debounce, eagerImage, - getBreakPoint, getMeta, + publishBreakpointChange, } from './libs.js'; function getInfo(block) { @@ -54,17 +53,21 @@ function includesInfo(infos, search) { } async function init() { + publishBreakpointChange(); ComponentMixin.getMixins(); // mechanism of retrieving lang to be used in the app - document.documentElement.lang = document.documentElement.lang || 'en'; + // TODO - set this based on url structure or meta tag for current path + document.documentElement.lang ||= 'en'; initEagerImages(); + // query only known blocks + const blockSelectors = `.${config.blocks.join(', .')}`; const blocks = [ document.body.querySelector(config.semanticBlocks[0]), - ...document.querySelectorAll('[class]:not([class^=style]'), - document.body.querySelector(config.semanticBlocks.slice(1).join(',')), + ...document.querySelectorAll(blockSelectors), + ...document.body.querySelectorAll(config.semanticBlocks.slice(1).join(',')), ]; const data = getInfos(blocks); @@ -80,18 +83,6 @@ async function init() { }); // timeout for the rest to proper prioritize in case of stalled loading lazy.map(({ name, el }) => setTimeout(() => start({ name, el }))); - - // reload on breakpoint change to reset params and variables - window.raqnBreakpoint = getBreakPoint(); - window.addEventListener( - 'resize', - debounce(() => { - // only on width / breakpoint changes - if (window.raqnBreakpoint !== getBreakPoint()) { - window.location.reload(); - } - }, 100), - ); } init(); diff --git a/scripts/libs.js b/scripts/libs.js index f1d338a2..38d74d2a 100644 --- a/scripts/libs.js +++ b/scripts/libs.js @@ -1,7 +1,27 @@ +import { publish } from './pubsub.js'; + export const config = { semanticBlocks: ['header', 'footer'], + blocks: [ + 'accordion', + 'breadcrumbs', + 'button', + 'card', + 'external', + 'hero', + 'icon', + 'navigation', + 'router', + 'section-metadata', + 'theme', + 'wrapper', + ], + mixinsBlocks: [ + 'column', + ], breakpoints: { - s: 0, + xs: 0, + s: 480, m: 768, l: 1024, xl: 1280, @@ -14,19 +34,91 @@ export const config = { }, }; +export function matchMediaQuery(breakpointMin, breakpointMax) { + const min = `(min-width: ${breakpointMin}px)`; + const max = breakpointMax ? ` and (max-width: ${breakpointMax}px)` : ''; + + return window.matchMedia(`${min}${max}`); +} + export function getBreakPoints() { - window.raqnBreakpoints = window.raqnBreakpoints || {}; - const breakpoints = Object.keys(config.breakpoints); - window.raqnBreakpoints = breakpoints.filter( - (bp) => matchMedia(`(min-width: ${config.breakpoints[bp]}px)`).matches, - ); + window.raqnBreakpoints ??= { + ordered: [], + byName: {}, + activeMinMax: [], + activeMin: null, + }; + + if (window.raqnBreakpoints.ordered.length) return window.raqnBreakpoints; + + window.raqnBreakpoints.ordered = Object.entries(config.breakpoints) + .sort((a, b) => a[1] - b[1]) + .map(([breakpointMinName, breakpointMin], index, arr) => { + const [, breakpointNext] = arr[index + 1] || []; + const breakpointMax = breakpointNext ? breakpointNext - 1 : null; + const breakpoint = { + name: breakpointMinName, + min: breakpointMin, + max: breakpointMax, + matchMediaMin: matchMediaQuery(breakpointMin), + matchMediaMinMax: matchMediaQuery(breakpointMin, breakpointMax), + }; + window.raqnBreakpoints.byName[breakpointMinName] = breakpoint; + if (breakpoint.matchMediaMin.matches) { + (window.raqnBreakpoints.activeMin ??= []).push({ ...breakpoint }); + } + if (breakpoint.matchMediaMinMax.matches) { + window.raqnBreakpoints.activeMinMax = { ...breakpoint }; + } + return { ...breakpoint }; + }); + return window.raqnBreakpoints; } -export function getBreakPoint() { - const b = getBreakPoints(); - return b[b.length - 1]; -} +// This will trigger a `matches = true` event on both increasing and decreasing the viewport size for each viewport type. +// No need for throttle here as the events are only triggered once at a time when the exact condition is valid. +export function publishBreakpointChange() { + const breakpoints = getBreakPoints(); + + if (breakpoints.listenersInitialized) return; + breakpoints.ordered.forEach((breakpoint) => { + breakpoint.matchMediaMinMax.addEventListener('change', (e) => { + + e.raqnBreakpoint = { + ...breakpoint, + }; + + if (e.matches) { + e.previousRaqnBreakpoint = { + ...breakpoints.activeMinMax, + }; + breakpoints.activeMinMax = { ...breakpoint }; + breakpoints.activeMin = breakpoints.ordered.filter((br) => br.min <= breakpoint.min); + } + /** + * Based on the breakpoints list there will always be + * one matching breakpoint and one not matching breakpoint + * at fired the same time. + * + * To prevent a subscription callbacks to be fired 2 times in a row, + * once for matching breakpoint and once for the not matching breakpoint + * it's advisable to use wither a matching or a non matching event + */ + // general event fired for matching and non matching breakpoints + publish('breakpoint::change', e); + publish(`breakpoint::change::${breakpoint.name}`, e); + if (e.matches) { + publish('breakpoint::change::matches', e); + publish(`breakpoint::change::matches::${breakpoint.name}`); + } else { + publish('breakpoint::change::not::matches', e); + publish(`breakpoint::change::not::matches::${breakpoint.name}`); + } + }); + }); + breakpoints.listenersInitialized = true; +}; export const debounce = (func, wait, immediate) => { let timeout; @@ -65,51 +157,74 @@ export function getMeta(name) { return meta.content; } -export function collectParams(blockName, classes, mixins, knownAttributes) { - const mediaParams = {}; - const allKnownAttributes = [ - ...(knownAttributes || []), - ...mixins.map((mixin) => mixin.observedAttributes || []).flat(), - ]; - return { - ...Array.from(classes) - .filter((c) => c !== blockName && c !== 'block') - .reduce((acc, c) => { - let value = c; - const breakpoint = Object.keys(config.breakpoints).find((b) => c.startsWith(`${b}-`)); - if(breakpoint) { - if(breakpoint === getBreakPoint()) { - value = value.slice(breakpoint.length + 1); - } else { - // skip as param applies only for a different breakpoint - return acc; - } +export function collectAttributes(blockName, classes, mixins, knownAttributes = [], element = null) { + const mediaAttributes = {}; + // inherit default param values + const attributesValues = element?.attributesValues || {}; + + const mixinKnownAttributes = mixins.flatMap((mixin) => mixin.observedAttributes || []); + const attrs = Array.from(classes) + .filter((c) => c !== blockName && 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 activeBreakpoint = getBreakPoints().activeMinMax.name; + + if (classBreakpoint) { + value = value.slice(classBreakpoint.length + 1); + } + + let key = 'class'; + const isClassValue = value.startsWith(key); + if (isClassValue) { + value = value.slice(key.length + 1); + } else { + isKnownAttribute = knownAttributes.find((attribute) => value.startsWith(`${attribute}-`)); + isMixinKnownAttributes = mixinKnownAttributes.find((attribute) => value.startsWith(`${attribute}-`)); + const getKnownAttribute = isKnownAttribute || isMixinKnownAttributes; + if (getKnownAttribute) { + key = getKnownAttribute; + value = value.slice(getKnownAttribute.length + 1); } + } + const isClass = key === 'class'; + if (isKnownAttribute || isClass) attributesValues[key] ??= {}; - // known attributes will be set directly to the element, all other classes will stay in the class attribute - let key = 'class'; - if(value.startsWith(key)) { - value = value.slice(key.length + 1); - } else { - const knownAttribute = allKnownAttributes.find((attribute) => value.startsWith(`${attribute}-`)); - if(knownAttribute) { - key = knownAttribute; - value = value.slice(knownAttribute.length + 1); + // media params always overwrite + if (classBreakpoint) { + if (classBreakpoint === activeBreakpoint) { + mediaAttributes[key] = value; + } + if (isKnownAttribute) attributesValues[key][classBreakpoint] = value; + if (isClass) { + if (attributesValues[key][classBreakpoint]) { + attributesValues[key][classBreakpoint] += ` ${value}`; + } else { + attributesValues[key][classBreakpoint] = value; } } - - // media params always overwrite - if(breakpoint) { - mediaParams[key] = value; // support multivalue attributes - } else if (acc[key]) { - acc[key] += ` ${value}`; - } else { - acc[key] = value; - } - return acc; - }, {}), - ...mediaParams, + } else if (acc[key]) { + acc[key] += ` ${value}`; + } else { + acc[key] = value; + } + + if (isKnownAttribute || isClass) attributesValues[key].all = acc[key]; + + return acc; + }, {}); + + return { // TODO improve how classes are collected and merged. + currentAttributes: { + ...attrs, + ...mediaAttributes, + ...((attrs.class || mediaAttributes.class) && { class: `${attrs.class ? attrs.class : ''}${mediaAttributes.class ? ` ${ mediaAttributes.class}` : ''}` }), + }, + attributesValues, }; } @@ -127,9 +242,9 @@ export function loadModule(urlWithoutExtension) { } else { resolve(); } - }).catch(() => + }).catch((error) => // eslint-disable-next-line no-console - console.trace(`could not load module style`, urlWithoutExtension), + console.trace('could not load module style', urlWithoutExtension, error), ); return { css, js }; diff --git a/scripts/pubsub.js b/scripts/pubsub.js new file mode 100644 index 00000000..4f21b20f --- /dev/null +++ b/scripts/pubsub.js @@ -0,0 +1,128 @@ +/** + * + * Very simple message/pubsub implementation with singleton storage. + */ + +window.nc ??= {}; +window.nc.actions ??= {}; +const { actions } = window.nc; + +export const subscribe = (message, action, options = {}) => { + const { scope = null } = options; + + if (scope) { + (scope.activeSubscriptions ??= []).push({ message, action }); + } + + if (actions[message]) { + actions[message].push(action); + } else { + actions[message] = [action]; + } +}; + +export const unsubscribe = (message, action, options = {}) => { + const { scope = null } = options; + + if (scope) { + let toRemoveFromScope = -1; + (scope.activeSubscriptions ??= []).find((sub, i) => { + if (sub.message === message && sub.action === action) { + toRemoveFromScope = i; + } + }); + if (toRemoveFromScope > -1) { + scope.activeSubscriptions.splice(toRemoveFromScope, 1); + } + } + + if (actions[message]) { + const toRemove = actions[message].indexOf(action); + actions[message].splice(toRemove, 1); + } +}; + +export const unsubscribeAll = (options = {}) => { + const { message, scope = null, exactFit = true } = options; + + if (scope) { + scope.activeSubscriptions.forEach(({ message: scopeMessage, action }) => { + unsubscribe(scopeMessage, action); + }); + scope.activeSubscriptions = []; + return; + } + + Object.keys(actions) + .forEach((key) => { + if (exactFit ? key === message : key.includes(message)) { + delete actions[key]; + } + }); +}; + +export const callStack = (message, params, options) => { + const { targetOrigin = window.origin, callStackAscending = true } = options; + + if (!['*', window.origin].includes(targetOrigin)) return; + + if (actions[message]) { + const messageCallStack = Array.from(actions[message]); // copy array + // call all actions by last one registered + let prevent = false; + + // Some current usages of `publish` are not passing `params` as an object. + // For these cases the option to `stopImmediatePropagation` will not be available. + if (params && typeof params === 'object' && !Array.isArray(params)) { + params.stopImmediatePropagation = () => { + prevent = true; + }; + } + + // run the call stack unless `stopImmediatePropagation()` was called in previous action (prevent further actions to run) + const callStackMethod = callStackAscending ? 'shift' : 'pop'; + while (!prevent && messageCallStack.length > 0) { + const action = messageCallStack[callStackMethod](); + action(params); + } + } +}; + +export const postMessage = (message, params, options = {}) => { + const { usePostMessage = false, targetOrigin = window.origin } = options; + if (!usePostMessage) return; + + let data = { message }; + try { + data = JSON.parse(JSON.stringify({ message, params })); + } catch (error) { + // some objects cannot be passed by post messages like when passing htmlElements. + // for those that can be published but are not compatible with postMessages we don't send params + // eslint-disable-next-line no-console + console.warn(error); + } + + window.postMessage(data, targetOrigin); +}; + +export const publish = (message, params, options = {}) => { + const { usePostMessage = false } = options; + if (!usePostMessage) { + callStack(message, params, options); + return; + } + + postMessage(message, params, options); +}; + +if (!window.messageListenerAdded) { + window.messageListenerAdded = true; + window.addEventListener('message', (e) => { + if (e && e.data) { + const { message, params } = e.data; + if (message && !Array.isArray(params)) { + callStack(message, params, e.origin); + } + } + }); +} \ No newline at end of file diff --git a/styles/styles.css b/styles/styles.css index 8faf3791..4bb0a9b2 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -62,14 +62,14 @@ 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 { margin-top: var(--scope-header-height, 64px); From 62102abe893822cf70f882ca1c150cb427eeb866 Mon Sep 17 00:00:00 2001 From: Florin Raducan Date: Fri, 29 Mar 2024 10:25:22 +0200 Subject: [PATCH 2/2] 456187 - Simplify breakpoints handling --- .prettierrc | 2 +- blocks/accordion/accordion.js | 15 +---- blocks/card/card.js | 8 +-- blocks/hero/hero.js | 2 +- blocks/icon/icon.js | 18 +----- blocks/navigation/navigation.js | 6 +- blocks/router/router.js | 5 +- scripts/component-base.js | 97 +++++++++++------------------ scripts/component-loader.js | 9 ++- scripts/init.js | 6 +- scripts/libs.js | 107 +++++++++++--------------------- 11 files changed, 90 insertions(+), 185 deletions(-) diff --git a/.prettierrc b/.prettierrc index 856658af..0fe577f1 100644 --- a/.prettierrc +++ b/.prettierrc @@ -8,7 +8,7 @@ "bracketSameLine": false, "jsxBracketSameLine": false, "jsxSingleQuote": true, - "printWidth": 80, + "printWidth": 120, "proseWrap": "preserve", "quoteProps": "as-needed", "requirePragma": false, diff --git a/blocks/accordion/accordion.js b/blocks/accordion/accordion.js index 8d072a8e..2cc41191 100644 --- a/blocks/accordion/accordion.js +++ b/blocks/accordion/accordion.js @@ -51,14 +51,8 @@ export default class Accordion extends ComponentBase { if (content) { content.classList.toggle('active'); control.classList.toggle('active'); - control.setAttribute( - 'aria-expanded', - content.classList.contains('active'), - ); - content.setAttribute( - 'aria-hidden', - !content.classList.contains('active'), - ); + control.setAttribute('aria-expanded', content.classList.contains('active')); + content.setAttribute('aria-hidden', !content.classList.contains('active')); } } @@ -72,10 +66,7 @@ export default class Accordion extends ComponentBase { content.setAttribute('role', 'region'); content.setAttribute('aria-hidden', true); content.classList.add('accordion-content'); - content.setAttribute( - 'aria-labelledby', - content.previousElementSibling.id, - ); + content.setAttribute('aria-labelledby', content.previousElementSibling.id); }); } } diff --git a/blocks/card/card.js b/blocks/card/card.js index 8019a96e..b2a26d1c 100644 --- a/blocks/card/card.js +++ b/blocks/card/card.js @@ -6,9 +6,7 @@ export default class Card extends ComponentBase { ready() { if (this.getAttribute('button') === 'true') { - Array.from(this.querySelectorAll('a')).forEach((a) => - this.convertLink(a), - ); + Array.from(this.querySelectorAll('a')).forEach((a) => this.convertLink(a)); } this.eager = parseInt(this.getAttribute('eager') || 0, 10); this.classList.add('inner'); @@ -39,12 +37,12 @@ export default class Card extends ComponentBase { this.style.setProperty('--card-columns', this.area); } - onAttrChanged_columns({ oldValue, newValue }) { + onAttributeColumnsChanged({ oldValue, newValue }) { if (oldValue === newValue) return; this.setupColumns(newValue); } - onAttrChanged_ratio({ oldValue, newValue }) { + onAttributeRatioChanged({ oldValue, newValue }) { if (oldValue === newValue) return; this.setupRatio(newValue); } diff --git a/blocks/hero/hero.js b/blocks/hero/hero.js index a9b01bea..af543724 100644 --- a/blocks/hero/hero.js +++ b/blocks/hero/hero.js @@ -11,7 +11,7 @@ export default class Hero extends ComponentBase { this.setAttribute('aria-label', 'hero'); } - onAttrChanged_order({ newValue }) { + onAttributeOrderChanged({ newValue }) { this.style.setProperty('--hero-hero-order', newValue); } } diff --git a/blocks/icon/icon.js b/blocks/icon/icon.js index 0db67d2a..90bacd1f 100644 --- a/blocks/icon/icon.js +++ b/blocks/icon/icon.js @@ -72,14 +72,8 @@ export default class Icon extends ComponentBase { html: this.svg // rescope ids and references to avoid clashes across icons; .replaceAll(/ id="([^"]+)"/g, (_, id) => ` id="${iconName}-${id}"`) - .replaceAll( - /="url\(#([^)]+)\)"/g, - (_, id) => `="url(#${iconName}-${id})"`, - ) - .replaceAll( - / xlink:href="#([^"]+)"/g, - (_, id) => ` xlink:href="#${iconName}-${id}"`, - ), + .replaceAll(/="url\(#([^)]+)\)"/g, (_, id) => `="url(#${iconName}-${id})"`) + .replaceAll(/ xlink:href="#([^"]+)"/g, (_, id) => ` xlink:href="#${iconName}-${id}"`), }; } else { const dummy = document.createElement('div'); @@ -88,13 +82,7 @@ export default class Icon extends ComponentBase { const width = svg.getAttribute('width'); const height = svg.getAttribute('height'); const viewBox = svg.getAttribute('viewBox'); - svg.innerHTML = this.iconTemplate( - iconName, - svg, - viewBox, - width, - height, - ); + svg.innerHTML = this.iconTemplate(iconName, svg, viewBox, width, height); this.cache[iconName].width = width; this.cache[iconName].height = height; this.cache[iconName].viewBox = viewBox; diff --git a/blocks/navigation/navigation.js b/blocks/navigation/navigation.js index 1a628676..56edfb06 100644 --- a/blocks/navigation/navigation.js +++ b/blocks/navigation/navigation.js @@ -55,7 +55,7 @@ export default class Navigation extends ComponentBase { if (!this.navContentInit) { this.navContentInit = true; this.setupClasses(this.navContent); - }; + } this.nav.append(this.navContent); } @@ -66,13 +66,13 @@ export default class Navigation extends ComponentBase { start({ name: 'accordion' }); this.setupClasses(this.navCompactedContent, true); this.navCompactedContent.addEventListener('click', (e) => this.activate(e)); - }; + } this.nav.append(this.createButton()); this.nav.append(this.navCompactedContent); } - onAttrChanged_compact({ newValue }) { + onAttributeCompactChanged({ newValue }) { if (!this.initialized) return; this.isCompact = newValue === 'true'; this.nav.innerHTML = ''; diff --git a/blocks/router/router.js b/blocks/router/router.js index 9d488234..29fafb15 100644 --- a/blocks/router/router.js +++ b/blocks/router/router.js @@ -17,10 +17,7 @@ export default class Router extends ComponentBase { document.addEventListener( 'click', (event) => { - if ( - event.target.tagName === 'A' && - event.target.href.startsWith(window.location.origin) - ) { + if (event.target.tagName === 'A' && event.target.href.startsWith(window.location.origin)) { event.preventDefault(); this.setAttribute('external', event.target.href); } diff --git a/scripts/component-base.js b/scripts/component-base.js index f9bacfe0..ddc79f10 100644 --- a/scripts/component-base.js +++ b/scripts/component-base.js @@ -1,63 +1,32 @@ import { start, startBlock } from './init.js'; -import { getBreakPoints } from './libs.js'; -import { publish, subscribe, unsubscribe, unsubscribeAll } from './pubsub.js'; +import { getBreakPoints, listenBreakpointChange, camelCaseAttr, capitalizeCaseAttr } from './libs.js'; export default class ComponentBase extends HTMLElement { static get knownAttributes() { - return [ - ...(Object.getPrototypeOf(this).knownAttributes || []), - ...(this.observedAttributes || []), - ]; + return [...(Object.getPrototypeOf(this).knownAttributes || []), ...(this.observedAttributes || [])]; } constructor() { super(); + this.blockName = 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.activeSubscriptions = []; // populated by the internal pubSub methods this.attributesValues = {}; // the values are set by the component loader + this.config = {}; this.setBinds(); } - config = { - subscriptions: { - breakpointMatches: 'breakpoint::change::matches', - }, - }; - setBinds() { - this.onBreakpointMatches = this.onBreakpointMatches.bind(this); - } - - // Use only the internal pub-sub methods - subscribe(event, callback, options) { - subscribe(event, callback, { scope: this, ...options }); - } - - publish(event, data, options) { - publish(event, data, options); - } - - unsubscribe(event, callback, options) { - unsubscribe(event, callback, { scope: this, ...options }); - } - - unsubscribeAll(options) { - unsubscribeAll({ scope: this, ...options }); + this.onBreakpointChange = this.onBreakpointChange.bind(this); } - disconnectedCallback() { - this.unsubscribeAll(); // must unsubscribe each time the element is added to the document - } - - initSubscriptions() { - this.subscribe(this.config.subscriptions.breakpointMatches, this.onBreakpointMatches); - } - - onBreakpointMatches(e) { - this.setBreakpointAttributesValues(e); + onBreakpointChange(e) { + if (e.matches) { + this.setBreakpointAttributesValues(e); + } } setBreakpointAttributesValues(e) { @@ -65,7 +34,7 @@ export default class ComponentBase extends HTMLElement { const isAttribute = attribute !== 'class'; if (isAttribute) { const newValue = breakpointsValues[e.raqnBreakpoint.name] ?? breakpointsValues.all; - // this will trigger the `attributeChangedCallback` and a `onAttrChanged_${name}` method + // this will trigger the `attributeChangedCallback` and a `onAttribute${capitalizedAttr}Changed` method // should be defined to handle the attribute value change if (newValue ?? false) { if (this.getAttribute(attribute) === newValue) return; @@ -74,8 +43,8 @@ export default class ComponentBase extends HTMLElement { this.removeAttribute(attribute, newValue); } } else { - const prevClasses = (breakpointsValues[e.previousRaqnBreakpoint.name] ?? '').split(' ').filter((x)=> x); - const newClasses = (breakpointsValues[e.raqnBreakpoint.name] ?? '').split(' ').filter((x)=> x); + const prevClasses = (breakpointsValues[e.previousRaqnBreakpoint.name] ?? '').split(' ').filter((x) => x); + const newClasses = (breakpointsValues[e.raqnBreakpoint.name] ?? '').split(' ').filter((x) => x); const removeClasses = prevClasses.filter((prevClass) => !newClasses.includes(prevClass)); const addClasses = newClasses.filter((newClass) => !prevClasses.includes(newClass)); @@ -87,22 +56,28 @@ export default class ComponentBase extends HTMLElement { /** * Attributes are assigned before the `connectedCallback` is triggered. - * In some cases a check for `this.initialized` inside `onAttrChanged_${name}` might be required + * In some cases a check for `this.initialized` inside `onAttribute${capitalizedAttr}Changed` might be required */ attributeChangedCallback(name, oldValue, newValue) { + const camelAttr = camelCaseAttr(name); + const capitalizedAttr = capitalizeCaseAttr(name); // handle case when attribute is removed from the element // default to attribute breakpoint value - const defaultNewVal = (newValue === null) ? (this.getBreakpointAttrVal(name) ?? null) : newValue; - this[`onAttrChanged_${name}`]?.({ oldValue, newValue: defaultNewVal }); + const defaultNewVal = newValue === null ? this.getBreakpointAttrVal(camelAttr) ?? null : newValue; + this[`onAttribute${capitalizedAttr}Changed`]?.({ oldValue, newValue: defaultNewVal }); } getBreakpointAttrVal(attr) { - const { name: activeBrName } = this.breakpoints.activeMinMax; + const { name: activeBrName } = this.breakpoints.active; const attribute = this.attributesValues?.[attr]; if (!attribute) return undefined; return attribute?.[activeBrName] ?? attribute?.all; } + addListeners() { + listenBreakpointChange(this.onBreakpointChange); + } + async connectedCallback() { this.initialized = this.getAttribute('initialized'); this.initSubscriptions(); // must subscribe each time the element is added to the document @@ -114,21 +89,17 @@ export default class ComponentBase extends HTMLElement { if (this.dependencies.length > 0) { await Promise.all(this.dependencies.map((dep) => start({ name: dep }))); } - this.connected(); - this.ready(); + this.connected(); // manipulate the html + this.addListeners(); // html is ready add listeners + 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: { block: this } })); } } async loadFragment(path) { - const response = await fetch( - `${path}`, - window.location.pathname.endsWith(path) ? { cache: 'reload' } : {}, - ); + const response = await fetch(`${path}`, window.location.pathname.endsWith(path) ? { cache: 'reload' } : {}); return this.processFragment(response); } @@ -136,14 +107,16 @@ export default class ComponentBase extends HTMLElement { if (response.ok) { const html = await response.text(); this.innerHTML = html; - return this.querySelectorAll(':scope > div > div').forEach((block) => - startBlock(block), - ); + return this.querySelectorAll(':scope > div > div').forEach((block) => startBlock(block)); } return response; } - connected() { } + initSubscriptions() {} + + connected() {} + + ready() {} - ready() { } + disconnectedCallback() {} } diff --git a/scripts/component-loader.js b/scripts/component-loader.js index fec47c00..097c64fd 100644 --- a/scripts/component-loader.js +++ b/scripts/component-loader.js @@ -2,7 +2,6 @@ import { config, collectAttributes, loadModule } from './libs.js'; import ComponentMixin from './component-mixin.js'; export default class ComponentLoader { - constructor(blockName, element) { window.raqnComponents = window.raqnComponents || {}; this.blockName = blockName; @@ -47,7 +46,7 @@ export default class ComponentLoader { const initialized = new Promise((resolve) => { const initListener = async (event) => { - if(event.detail.block === element) { + if (event.detail.block === element) { element.removeEventListener('initialized', initListener); await ComponentMixin.startAll(element); resolve(); @@ -69,15 +68,15 @@ export default class ComponentLoader { const { css, js } = loadModule(this.pathWithoutExtension); cssLoaded = css; const mod = await js; - if(this.isWebComponentClass(mod.default)) { + if (this.isWebComponentClass(mod.default)) { window.customElements.define(this.webComponentName, mod.default); } return mod.default; })(); } this.handler = await this.handler; - if(this.block) { - if(this.isWebComponentClass()) { + if (this.block) { + if (this.isWebComponentClass()) { await this.setupElement(); } else { await this.handler(this.block); diff --git a/scripts/init.js b/scripts/init.js index f5fda5ac..027ea915 100644 --- a/scripts/init.js +++ b/scripts/init.js @@ -4,7 +4,6 @@ import { config, eagerImage, getMeta, - publishBreakpointChange, } from './libs.js'; function getInfo(block) { @@ -53,7 +52,6 @@ function includesInfo(infos, search) { } async function init() { - publishBreakpointChange(); ComponentMixin.getMixins(); // mechanism of retrieving lang to be used in the app @@ -62,11 +60,9 @@ async function init() { initEagerImages(); - // query only known blocks - const blockSelectors = `.${config.blocks.join(', .')}`; const blocks = [ document.body.querySelector(config.semanticBlocks[0]), - ...document.querySelectorAll(blockSelectors), + ...document.querySelectorAll('[class]:not([class^=style]'), ...document.body.querySelectorAll(config.semanticBlocks.slice(1).join(',')), ]; diff --git a/scripts/libs.js b/scripts/libs.js index 38d74d2a..531ccbd9 100644 --- a/scripts/libs.js +++ b/scripts/libs.js @@ -1,24 +1,5 @@ -import { publish } from './pubsub.js'; - export const config = { semanticBlocks: ['header', 'footer'], - blocks: [ - 'accordion', - 'breadcrumbs', - 'button', - 'card', - 'external', - 'hero', - 'icon', - 'navigation', - 'router', - 'section-metadata', - 'theme', - 'wrapper', - ], - mixinsBlocks: [ - 'column', - ], breakpoints: { xs: 0, s: 480, @@ -34,6 +15,9 @@ export const config = { }, }; +export const camelCaseAttr = (val) => val.replace(/-([a-z])/g, (k) => k[1].toUpperCase()); +export const capitalizeCaseAttr = (val) => camelCaseAttr(val.replace(/^[a-z]/g, (k) => k.toUpperCase())); + export function matchMediaQuery(breakpointMin, breakpointMax) { const min = `(min-width: ${breakpointMin}px)`; const max = breakpointMax ? ` and (max-width: ${breakpointMax}px)` : ''; @@ -45,10 +29,10 @@ export function getBreakPoints() { window.raqnBreakpoints ??= { ordered: [], byName: {}, - activeMinMax: [], - activeMin: null, + active: null, }; + // return if already set if (window.raqnBreakpoints.ordered.length) return window.raqnBreakpoints; window.raqnBreakpoints.ordered = Object.entries(config.breakpoints) @@ -60,15 +44,12 @@ export function getBreakPoints() { name: breakpointMinName, min: breakpointMin, max: breakpointMax, - matchMediaMin: matchMediaQuery(breakpointMin), - matchMediaMinMax: matchMediaQuery(breakpointMin, breakpointMax), + matchMedia: matchMediaQuery(breakpointMin, breakpointMax), }; window.raqnBreakpoints.byName[breakpointMinName] = breakpoint; - if (breakpoint.matchMediaMin.matches) { - (window.raqnBreakpoints.activeMin ??= []).push({ ...breakpoint }); - } - if (breakpoint.matchMediaMinMax.matches) { - window.raqnBreakpoints.activeMinMax = { ...breakpoint }; + + if (breakpoint.matchMedia.matches) { + window.raqnBreakpoints.active = { ...breakpoint }; } return { ...breakpoint }; }); @@ -76,49 +57,26 @@ export function getBreakPoints() { return window.raqnBreakpoints; } -// This will trigger a `matches = true` event on both increasing and decreasing the viewport size for each viewport type. -// No need for throttle here as the events are only triggered once at a time when the exact condition is valid. -export function publishBreakpointChange() { +export function listenBreakpointChange(callback) { const breakpoints = getBreakPoints(); + let { active } = breakpoints; - if (breakpoints.listenersInitialized) return; breakpoints.ordered.forEach((breakpoint) => { - breakpoint.matchMediaMinMax.addEventListener('change', (e) => { - - e.raqnBreakpoint = { - ...breakpoint, - }; + breakpoint.matchMedia.addEventListener('change', (e) => { + e.raqnBreakpoint = { ...breakpoint }; if (e.matches) { - e.previousRaqnBreakpoint = { - ...breakpoints.activeMinMax, - }; - breakpoints.activeMinMax = { ...breakpoint }; - breakpoints.activeMin = breakpoints.ordered.filter((br) => br.min <= breakpoint.min); - } - /** - * Based on the breakpoints list there will always be - * one matching breakpoint and one not matching breakpoint - * at fired the same time. - * - * To prevent a subscription callbacks to be fired 2 times in a row, - * once for matching breakpoint and once for the not matching breakpoint - * it's advisable to use wither a matching or a non matching event - */ - // general event fired for matching and non matching breakpoints - publish('breakpoint::change', e); - publish(`breakpoint::change::${breakpoint.name}`, e); - if (e.matches) { - publish('breakpoint::change::matches', e); - publish(`breakpoint::change::matches::${breakpoint.name}`); - } else { - publish('breakpoint::change::not::matches', e); - publish(`breakpoint::change::not::matches::${breakpoint.name}`); + e.previousRaqnBreakpoint = { ...active }; + active = { ...breakpoint }; + if (breakpoints.active.name !== breakpoint.name) { + breakpoints.active = { ...breakpoint }; + } } + + callback?.(e); }); }); - breakpoints.listenersInitialized = true; -}; +} export const debounce = (func, wait, immediate) => { let timeout; @@ -171,7 +129,7 @@ export function collectAttributes(blockName, classes, mixins, knownAttributes = let isMixinKnownAttributes = null; const classBreakpoint = Object.keys(config.breakpoints).find((b) => c.startsWith(`${b}-`)); - const activeBreakpoint = getBreakPoints().activeMinMax.name; + const activeBreakpoint = getBreakPoints().active.name; if (classBreakpoint) { value = value.slice(classBreakpoint.length + 1); @@ -190,20 +148,22 @@ export function collectAttributes(blockName, classes, mixins, knownAttributes = value = value.slice(getKnownAttribute.length + 1); } } + const isClass = key === 'class'; - if (isKnownAttribute || isClass) attributesValues[key] ??= {}; + const camelCaseKey = camelCaseAttr(key); + if (isKnownAttribute || isClass) attributesValues[camelCaseKey] ??= {}; // media params always overwrite if (classBreakpoint) { if (classBreakpoint === activeBreakpoint) { mediaAttributes[key] = value; } - if (isKnownAttribute) attributesValues[key][classBreakpoint] = value; + if (isKnownAttribute) attributesValues[camelCaseKey][classBreakpoint] = value; if (isClass) { - if (attributesValues[key][classBreakpoint]) { - attributesValues[key][classBreakpoint] += ` ${value}`; + if (attributesValues[camelCaseKey][classBreakpoint]) { + attributesValues[camelCaseKey][classBreakpoint] += ` ${value}`; } else { - attributesValues[key][classBreakpoint] = value; + attributesValues[camelCaseKey][classBreakpoint] = value; } } // support multivalue attributes @@ -213,16 +173,19 @@ export function collectAttributes(blockName, classes, mixins, knownAttributes = acc[key] = value; } - if (isKnownAttribute || isClass) attributesValues[key].all = acc[key]; + if (isKnownAttribute || isClass) attributesValues[camelCaseKey].all = acc[key]; return acc; }, {}); - return { // TODO improve how classes are collected and merged. + return { + // TODO improve how classes are collected and merged. currentAttributes: { ...attrs, ...mediaAttributes, - ...((attrs.class || mediaAttributes.class) && { class: `${attrs.class ? attrs.class : ''}${mediaAttributes.class ? ` ${ mediaAttributes.class}` : ''}` }), + ...((attrs.class || mediaAttributes.class) && { + class: `${attrs.class ? attrs.class : ''}${mediaAttributes.class ? ` ${mediaAttributes.class}` : ''}`, + }), }, attributesValues, };