From 48a51184e5badb85eb39ea94a599d91d637445ea Mon Sep 17 00:00:00 2001 From: Nitin Venkat Rachabathuni Date: Wed, 22 Jul 2026 13:21:07 +0530 Subject: [PATCH 1/3] feat: load Coveo Atomic 3.60 from CDN and fix Lit search UI Use cloud v3.60 (CORS-safe) instead of v3.13.0, wait on folded-result-list, and avoid Lit shadow DOM fights that left the search shimmer stuck. Co-authored-by: Cursor --- blocks/atomic-search/atomic-search.js | 21 +++++------ .../components/atomic-search-result.js | 36 +++++++++++++------ scripts/load-atomic-search-scripts.js | 24 ++++++++++++- 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/blocks/atomic-search/atomic-search.js b/blocks/atomic-search/atomic-search.js index a57312023e..bb5b1e6d62 100644 --- a/blocks/atomic-search/atomic-search.js +++ b/blocks/atomic-search/atomic-search.js @@ -156,8 +156,10 @@ export default function decorate(block) { atomicResultPageHandler(block.querySelector('atomic-results-per-page')); }; + // Wait for components we actually mount. Atomic 3.60+ lazy-registers CE definitions; + // we use atomic-folded-result-list (not atomic-result-list), so waiting on the latter hangs forever. Promise.all([ - customElements.whenDefined('atomic-result-list'), + customElements.whenDefined('atomic-folded-result-list'), customElements.whenDefined('atomic-result'), customElements.whenDefined('atomic-result-multi-value-text'), customElements.whenDefined('atomic-search-box'), @@ -166,11 +168,18 @@ export default function decorate(block) { customElements.whenDefined('atomic-breadbox'), customElements.whenDefined('atomic-pager'), customElements.whenDefined('atomic-no-results'), - customElements.whenDefined('atomic-search-box'), customElements.whenDefined('atomic-results-per-page'), customElements.whenDefined('atomic-notifications'), ]).then(() => { atomicNoResultHandler(block, placeholders); + // Register before commonActionHandler — facet init can fire FACET_LOADED synchronously. + document.addEventListener( + CUSTOM_EVENTS.FACET_LOADED, + () => { + resolveBlockLevelSkeleton(block); + }, + { once: true }, + ); commonActionHandler(); decorateIcons(block); let eventFiredOnPageLoad = false; @@ -224,14 +233,6 @@ export default function decorate(block) { filters: placeholders.searchFiltersLabel || 'Filters', }); - document.addEventListener( - CUSTOM_EVENTS.FACET_LOADED, - () => { - resolveBlockLevelSkeleton(block); - }, - { once: true }, - ); - const trackingHandler = (event) => { const isResizeAction = event?.detail?.callFrom === 'resize'; if (isResizeAction) { diff --git a/blocks/atomic-search/components/atomic-search-result.js b/blocks/atomic-search/components/atomic-search-result.js index 8849579533..9f8bd9b075 100644 --- a/blocks/atomic-search/components/atomic-search-result.js +++ b/blocks/atomic-search/components/atomic-search-result.js @@ -556,12 +556,20 @@ export default function atomicResultHandler(block, placeholders) { return; } container.parentElement.part.add('list-wrap'); - // Make result section hidden and start adding skeleton. - container.style.cssText = 'display: none;'; - container.dataset.view = isMobile() ? 'mobile' : 'desktop'; - const skeletonWrapper = htmlToElement(`
`); - skeletonWrapper.innerHTML = renderAtomicSekeletonUI(); - container.parentElement.appendChild(skeletonWrapper); + + // Atomic 3.60+ (Lit) re-renders its shadow DOM. Hiding/replacing nodes fights Lit and + // can wipe results permanently. Only show an in-shadow skeleton when results are not yet present. + const hasResultsAlready = shadow.querySelectorAll('atomic-result').length > 0; + if (!hasResultsAlready) { + container.style.cssText = 'display: none;'; + container.dataset.view = isMobile() ? 'mobile' : 'desktop'; + const skeletonWrapper = htmlToElement(`
`); + skeletonWrapper.innerHTML = renderAtomicSekeletonUI(); + container.parentElement.appendChild(skeletonWrapper); + } else { + container.dataset.view = isMobile() ? 'mobile' : 'desktop'; + baseElement.classList.remove('list-wrap-skeleton'); + } function onClearBtnClick() { const atomicBreadBox = document.querySelector('atomic-breadbox'); @@ -813,7 +821,10 @@ export default function atomicResultHandler(block, placeholders) { }; const updateAtomicResultUI = (callFrom) => { - const results = container.querySelectorAll('atomic-result'); + // Prefer light query on the list part; fall back to full shadow (Lit may move nodes). + const results = container.querySelectorAll('atomic-result').length + ? container.querySelectorAll('atomic-result') + : shadow.querySelectorAll('atomic-result'); const isMobileView = isMobile(); container.dataset.view = isMobileView ? 'mobile' : 'desktop'; results.forEach((resultElement, index) => { @@ -1173,8 +1184,13 @@ export default function atomicResultHandler(block, placeholders) { const resizeObserver = new ResizeObserver(debouncedResize); resizeObserver.observe(container); - // Add observer to check the loading of result items. - const observer = new MutationObserver(updateAtomicResultUI); - observer.observe(container, { childList: true, subtree: false }); + // Watch the whole shadow tree — Lit Atomic renders results reactively. + const observer = new MutationObserver(() => updateAtomicResultUI()); + observer.observe(shadow, { childList: true, subtree: true }); updateAtomicResultUI(); + + // Fail-safe: never leave the Lit result list permanently hidden behind our skeleton. + setTimeout(() => { + removeBlockSkeleton(); + }, 4000); } diff --git a/scripts/load-atomic-search-scripts.js b/scripts/load-atomic-search-scripts.js index 6d40d81213..09b8c75dbd 100644 --- a/scripts/load-atomic-search-scripts.js +++ b/scripts/load-atomic-search-scripts.js @@ -33,9 +33,31 @@ export const loadScript = (src, attrs = {}) => { return promise; }; +const loadStylesheet = (href) => { + if (document.querySelector(`link[href="${href}"]`)) { + return; + } + const link = document.createElement('link'); + link.rel = 'stylesheet'; + link.href = href; + document.head.appendChild(link); +}; + +/** + * Latest Atomic CDN (cloud-only). + * + * Use the minor segment (`v3.60`) — it tracks the latest 3.60.x and sends CORS + * (`Access-Control-Allow-Origin: *`). Patch URLs like `v3.60.1` currently omit CORS + * and break cross-origin ES module loads from EDS. + * + * @see https://docs.coveo.com/en/atomic/latest/usage/ + */ +const COVEO_ATOMIC_CDN = 'https://static.cloud.coveo.com/atomic/v3.60'; + export async function initiateCoveoAtomicSearch() { + loadStylesheet(`${COVEO_ATOMIC_CDN}/themes/coveo.css`); return new Promise((resolve, reject) => { - loadScript('https://static.cloud.coveo.com/atomic/v3.13.0/atomic.esm.js', { type: 'module' }) + loadScript(`${COVEO_ATOMIC_CDN}/atomic.esm.js`, { type: 'module', crossorigin: 'anonymous' }) .then(async () => { resolve(true); }) From fdaeaa1f96aabcbb7c387e43fe11b52fcdac085d Mon Sep 17 00:00:00 2001 From: Nitin Venkat Rachabathuni Date: Wed, 22 Jul 2026 23:47:53 +0530 Subject: [PATCH 2/3] fix: restore ExL search chrome after Coveo Atomic 3.60 bump Keep flat facets/results and 1440 layout parity with prod under Lit Atomic. Co-authored-by: Cursor --- blocks/atomic-search/atomic-search.css | 29 +++++++++++++-- .../components/atomic-search-result.js | 22 +++++++++--- .../components/atomic-search-template.js | 35 ++++++++++++++++--- 3 files changed, 75 insertions(+), 11 deletions(-) diff --git a/blocks/atomic-search/atomic-search.css b/blocks/atomic-search/atomic-search.css index 0e487377eb..f49103ef8c 100644 --- a/blocks/atomic-search/atomic-search.css +++ b/blocks/atomic-search/atomic-search.css @@ -25,6 +25,18 @@ --atomic-search-skeleton-margin-top: 0px; } +/* Flatten Atomic 3.60+ Lit theme tokens to match ExL prod (Atomic 3.13) chrome. */ +.atomic-search atomic-search-interface { + --atomic-border-radius: 0; + --atomic-border-radius-md: 0; + --atomic-border-radius-lg: 0; + --atomic-background: transparent; + + /* Kill Atomic 3.60 default 1.5rem/1rem layout gaps that inflate section margins. */ + --atomic-layout-spacing-x: 0; + --atomic-layout-spacing-y: 0; +} + .atomic-search-premium-search-wrapper { position: relative; min-height: var(--atomic-search-premium-search-height, 0); @@ -403,9 +415,11 @@ } @media only screen and (min-width: 1024px) { - .atomic-search atomic-search-interface atomic-search-layout[id^='atomic-search-layout'] { + .atomic-search atomic-search-interface atomic-search-layout[id^='atomic-search-layout'], + .atomic-search atomic-search-interface atomic-search-layout { grid-template-columns: 0 20% 80% !important; - grid-column-gap: 16px; + grid-column-gap: 0; + column-gap: 0; } .atomic-load-skeleton-main { @@ -476,7 +490,18 @@ } .section.atomic-search-container .atomic-search-wrapper { + box-sizing: border-box; + width: 100%; max-width: 1440px; + margin-left: auto; + margin-right: auto; + } + + .section.atomic-search-container .atomic-search-wrapper .atomic-search, + .section.atomic-search-container .atomic-search-wrapper atomic-search-interface, + .section.atomic-search-container .atomic-search-wrapper atomic-search-layout { + width: 100%; + max-width: 100%; } .atomic-search button.facet-close-btn { diff --git a/blocks/atomic-search/components/atomic-search-result.js b/blocks/atomic-search/components/atomic-search-result.js index 9f8bd9b075..661927986f 100644 --- a/blocks/atomic-search/components/atomic-search-result.js +++ b/blocks/atomic-search/components/atomic-search-result.js @@ -62,18 +62,22 @@ export const atomicResultStyles = ` .atomic-search-result-item.mobile-only .result-field.result-thumbnail { margin-top: 10px; } - .result-root.recommendation-badge { + /* Atomic 3.60 renders results under .result-component (was .result-root). */ + .result-root.recommendation-badge, + .result-component.recommendation-badge { margin: 40px 0px 0px; } .atomic-search-result-item .result-field.text-thumbnail { display: flex; gap: 18px; } - .atomic-search-result-item .result-field.text-thumbnail:not(:has(.result-thumbnail)) { + /* Atomic 3.60 keeps inactive field-condition nodes in the DOM with [hidden]; + :has(.result-thumbnail) alone falsely enables the video flex layout. */ + .atomic-search-result-item .result-field.text-thumbnail:not(:has(atomic-field-condition:not([hidden]) .result-thumbnail)) { gap: 0; display: block; } - .atomic-search-result-item .result-field.text-thumbnail:has(.result-thumbnail) .result-text { + .atomic-search-result-item .result-field.text-thumbnail:has(atomic-field-condition:not([hidden]) .result-thumbnail) .result-text { flex: 0 0 56%; } .atomic-search-result-item.result-item .thumbnail-wrapper { @@ -384,6 +388,11 @@ export const atomicResultListStyles = ` atomic-folded-result-list::part(result-list) { grid-row-gap: 0; } + atomic-folded-result-list::part(outline) { + border: none; + border-radius: 0; + background-color: transparent; + } atomic-folded-result-list::part(outline)::before { background-color:var(--footer-border-color); display: block; @@ -393,6 +402,9 @@ export const atomicResultListStyles = ` } atomic-folded-result-list::part(first-result) { padding-top: 1rem; + border: none; + border-radius: 0; + background-color: transparent; } atomic-folded-result-list::part(first-result)::before { display: none; @@ -909,8 +921,8 @@ export default function atomicResultHandler(block, placeholders) { const recommendationBadgeExists = !!resultItem.querySelector('.atomic-recommendation-badge'); if (recommendationBadgeExists) { - const resultRoot = resultShadow.querySelector('.result-root'); - resultRoot.classList.add('recommendation-badge'); + const resultRoot = resultShadow.querySelector('.result-root, .result-component'); + resultRoot?.classList.add('recommendation-badge'); } // Handle el_kudo_status field - support both legacy numeric and new user ID format diff --git a/blocks/atomic-search/components/atomic-search-template.js b/blocks/atomic-search/components/atomic-search-template.js index 091e69005b..eec80d9f98 100644 --- a/blocks/atomic-search/components/atomic-search-template.js +++ b/blocks/atomic-search/components/atomic-search-template.js @@ -30,6 +30,15 @@ const getCoveoAtomicMarkup = (placeholders) => {
{ } atomic-folded-result-list::part(outline) { padding: 0; + border: none; + border-radius: 0; + background-color: transparent; } .result-header-section { display: none; From 7fe430fa4edf78164c3b4a96092734be1a3dfa86 Mon Sep 17 00:00:00 2001 From: Nitin Venkat Rachabathuni Date: Thu, 23 Jul 2026 01:59:51 +0530 Subject: [PATCH 3/3] fix: stabilize Atomic 3.60 facet Clear All, hover, and multi-select Keep Clear All checkbox chrome in sync, restore child hover/Only and ticks, and stop Lit DOM churn that blocked selecting a second Product after Content Type filters. Co-authored-by: Cursor --- .../components/atomic-facet-engine-helpers.js | 2 +- .../components/atomic-search-breadbox.js | 75 ++++- .../components/atomic-search-facet.js | 258 +++++++++++++++--- .../components/atomic-search-result.js | 18 +- .../components/atomic-search-template.js | 59 +++- 5 files changed, 349 insertions(+), 63 deletions(-) diff --git a/blocks/atomic-search/components/atomic-facet-engine-helpers.js b/blocks/atomic-search/components/atomic-facet-engine-helpers.js index 58e0223af0..019ac1343a 100644 --- a/blocks/atomic-search/components/atomic-facet-engine-helpers.js +++ b/blocks/atomic-search/components/atomic-facet-engine-helpers.js @@ -167,7 +167,7 @@ export function applyFacetRawValuesToDom(parentWrapper, engineValues, placeholde delete li.dataset.updated; delete li.dataset.childfacet; delete li.dataset.parent; - li.querySelector('[part="only-facet-btn"]')?.remove(); + li.querySelector('[part~="only-facet-btn"]')?.remove(); delete li.dataset.onlyfacet; li.part?.remove('facet-child-element'); const labelEl = li.querySelector('label'); diff --git a/blocks/atomic-search/components/atomic-search-breadbox.js b/blocks/atomic-search/components/atomic-search-breadbox.js index eba7ac82ee..934ba35ad8 100644 --- a/blocks/atomic-search/components/atomic-search-breadbox.js +++ b/blocks/atomic-search/components/atomic-search-breadbox.js @@ -1,8 +1,12 @@ -import { CUSTOM_EVENTS, isUserClick } from './atomic-search-utils.js'; +import { CUSTOM_EVENTS, isUserClick, waitFor } from './atomic-search-utils.js'; export default function atomicBreadBoxHandler(baseElement) { + let listObserver = null; + let listObserveRetryPending = false; + function updateFilterClearBtnStyles(enabled) { const clearBtn = document.querySelector('.clear-label'); + if (!clearBtn) return; if (enabled) { clearBtn.classList.add('clear-btn-enabled'); } else { @@ -15,6 +19,22 @@ export default function atomicBreadBoxHandler(baseElement) { document.dispatchEvent(event); } + /** + * Atomic 3.13 toggled visibility via `.atomic-hidden` on the host. + * Atomic 3.60+ (Lit) often leaves className empty and only swaps display / crumbs. + * Drive Clear All from active breadcrumbs so both versions stay in sync with prod. + */ + function hasActiveBreadcrumbs() { + const list = baseElement.shadowRoot?.querySelector('[part="breadcrumb-list"]'); + if (!list) return false; + return list.querySelectorAll('li.breadcrumb').length > 0; + } + + function isBreadboxEnabled() { + if (baseElement.className?.includes('atomic-hidden')) return false; + return hasActiveBreadcrumbs(); + } + function attachListeners() { const parentWrapper = baseElement.shadowRoot.querySelector(`[part="breadcrumb-list"]`); if (!parentWrapper) { @@ -74,32 +94,56 @@ export default function atomicBreadBoxHandler(baseElement) { }); } - function observeBreadboxUI(enabled) { - const targetElement = baseElement.shadowRoot.querySelector(`[part="breadcrumb-list"]`); + function syncFromBreadcrumbs({ emitFilterUpdate = true } = {}) { + const enabled = isBreadboxEnabled(); + updateFilterClearBtnStyles(enabled); if (enabled) { - const observer = new MutationObserver(() => { - attachListeners(); - onFilterUpdate(); - }); - observer.observe(targetElement, { childList: true }); - } else { + attachListeners(); + } + if (emitFilterUpdate) { onFilterUpdate(); } } + function observeBreadcrumbList() { + const targetElement = baseElement.shadowRoot?.querySelector(`[part="breadcrumb-list"]`); + if (!targetElement) { + // Retry until Lit mounts the breadcrumb list; coalesce concurrent retries. + if (!listObserveRetryPending) { + listObserveRetryPending = true; + waitFor(() => { + listObserveRetryPending = false; + observeBreadcrumbList(); + }); + } + return; + } + listObserveRetryPending = false; + if (listObserver) { + listObserver.disconnect(); + } + listObserver = new MutationObserver(() => { + syncFromBreadcrumbs(); + }); + listObserver.observe(targetElement, { childList: true, subtree: true }); + syncFromBreadcrumbs(); + } + const observer = new MutationObserver((mutationsList) => { mutationsList.forEach((mutation) => { if (mutation.type === 'attributes' && mutation.attributeName === 'class') { - const enabled = !baseElement.className.includes('atomic-hidden'); - updateFilterClearBtnStyles(enabled); - observeBreadboxUI(enabled); - attachListeners(); + // Keep legacy Atomic 3.13 class-toggle path. + syncFromBreadcrumbs(); + observeBreadcrumbList(); + } + if (mutation.type === 'childList') { + observeBreadcrumbList(); } }); }); function onResultsUpdate() { - onFilterUpdate(); + syncFromBreadcrumbs(); } function hideSection() { @@ -110,9 +154,10 @@ export default function atomicBreadBoxHandler(baseElement) { baseElement.style.display = ''; } - document.addEventListener(CUSTOM_EVENTS.RESULT_UPDATED, onResultsUpdate, { once: true }); + document.addEventListener(CUSTOM_EVENTS.RESULT_UPDATED, onResultsUpdate); document.addEventListener(CUSTOM_EVENTS.NO_RESULT_FOUND, hideSection); document.addEventListener(CUSTOM_EVENTS.RESULT_FOUND, showSection); observer.observe(baseElement, { attributes: true, attributeFilter: ['class'], childList: true }); + observeBreadcrumbList(); } diff --git a/blocks/atomic-search/components/atomic-search-facet.js b/blocks/atomic-search/components/atomic-search-facet.js index 938cc34478..8eecaf1e42 100644 --- a/blocks/atomic-search/components/atomic-search-facet.js +++ b/blocks/atomic-search/components/atomic-search-facet.js @@ -69,41 +69,55 @@ export default function atomicFacetHandler(block, placeholders, searchInterface) } } } - if (!facet.dataset.evented) { - facet.dataset.evented = 'true'; - const clickHandler = (e, onlyOptionClicked = false) => { - const userClickAction = isUserClick(e); - if (!userClickAction || facet.dataset.filterclick === 'true') { - return; - } + const runFacetClick = (e, onlyOptionClicked = false) => { + const userClickAction = isUserClick(e); + if (!userClickAction || facet.dataset.filterclick === 'true') { + return; + } - const shimmer = atomicElement.shadowRoot.querySelector('.facet-shimmer'); - shimmer?.part.add('show-shimmer'); - const filtersChanged = syncFacetParentChildFilters({ facet, atomicElement, onlyOptionClicked }); - if (!filtersChanged && shimmer) { - shimmer.part.remove('show-shimmer'); - } - }; + const shimmer = atomicElement.shadowRoot.querySelector('.facet-shimmer'); + shimmer?.part.add('show-shimmer'); + const filtersChanged = syncFacetParentChildFilters({ facet, atomicElement, onlyOptionClicked }); + if (!filtersChanged && shimmer) { + shimmer.part.remove('show-shimmer'); + } + }; - const debouncedHandler = debounce(100, clickHandler); - facet.addEventListener('click', debouncedHandler); - const onlyFilterEl = facet.querySelector(`[part="only-facet-btn"]`); - if (onlyFilterEl) { - const filterHandler = (e) => { + // "Only" hover/click must bind even if row was evented before the Only span existed + // (Lit / hash-driven re-renders). Re-query the span on each event. + if (!facet.dataset.onlyHoverBound) { + facet.dataset.onlyHoverBound = 'true'; + facet.addEventListener('mouseenter', () => { + facet.querySelector('[part~="only-facet-btn"]')?.part.add('only-facet-visible'); + }); + facet.addEventListener('mouseleave', () => { + facet.querySelector('[part~="only-facet-btn"]')?.part.remove('only-facet-visible'); + }); + } + + if (!facet.dataset.onlyClickBound) { + facet.dataset.onlyClickBound = 'true'; + const debouncedOnlyClick = debounce(100, (e) => { + runFacetClick(e, true); + facet.dataset.filterclick = 'true'; + }); + // stopImmediatePropagation must be sync; debouncing it lets the row click win. + facet.addEventListener( + 'click', + (e) => { + const target = e.target instanceof Element ? e.target : e.target?.parentElement; + const onlyFilterEl = target?.closest?.('[part~="only-facet-btn"]'); + if (!onlyFilterEl || !facet.contains(onlyFilterEl)) return; e.stopImmediatePropagation(); - clickHandler(e, true); - facet.dataset.filterclick = 'true'; - }; - const debouncedFilterClickHandler = debounce(100, filterHandler); - onlyFilterEl.addEventListener('click', debouncedFilterClickHandler); - facet.addEventListener('mouseenter', () => { - onlyFilterEl.part.add('only-facet-visible'); - }); + debouncedOnlyClick(e); + }, + true, + ); + } - facet.addEventListener('mouseleave', () => { - onlyFilterEl.part.remove('only-facet-visible'); - }); - } + if (!facet.dataset.evented) { + facet.dataset.evented = 'true'; + facet.addEventListener('click', debounce(100, runFacetClick)); } }; @@ -117,7 +131,8 @@ export default function atomicFacetHandler(block, placeholders, searchInterface) const sortFacetsInOrder = (parentWrapper) => { const children = Array.from(parentWrapper.children); const sortedChildren = sortElementsByLabel(children); - parentWrapper.innerHTML = ''; + // Move nodes in place — do NOT clear innerHTML. Atomic 3.60 (Lit) owns these + // value rows; destroying/recreating them breaks checkbox click bindings until reload. sortedChildren.forEach((item) => parentWrapper.appendChild(item)); }; @@ -146,6 +161,12 @@ export default function atomicFacetHandler(block, placeholders, searchInterface) const updateChildElementUI = (parentWrapper, facetParent) => { const children = Array.from(parentWrapper.children); + // Product/Role/etc. have no parent/child rows — skip. Clearing values DOM here + // breaks Lit Atomic checkbox bindings after the first selection until reload. + if (!children.some((el) => el.dataset.childfacet === 'true')) { + return; + } + const finalList = []; let tempGroup = []; @@ -203,7 +224,7 @@ export default function atomicFacetHandler(block, placeholders, searchInterface) facetParent.dataset.observed = ''; } } - parentWrapper.innerHTML = ''; + // Reorder by moving existing nodes — never innerHTML='' (Lit Atomic 3.60). finalList.forEach((item) => parentWrapper.appendChild(item)); }; @@ -347,6 +368,121 @@ export default function atomicFacetHandler(block, placeholders, searchInterface) } }; + /** + * Atomic 3.60 (Lit) can leave checkbox visuals stale after Clear All / hash-driven + * deselect while the headless engine is already idle. Keep DOM parts/classes aligned + * with engine selection, and strip Atomic primary Tailwind utilities that fight ExL grey chrome. + */ + const PRIMARY_UTILITY_CLASSES = [ + 'bg-primary', + 'hover:bg-primary-light', + 'focus-visible:bg-primary-light', + 'hover:border-primary-light', + 'focus-visible:border-primary-light', + ]; + + // While Clear All is in flight, ignore engine selection until a post-clear RESULT_UPDATED + // reports idle (or a different selection than the clear-time baseline). + let facetClearInProgress = false; + let facetClearSafetyTimerId = 0; + let clearBaselineFingerprint = ''; + + const applyCheckboxVisualState = (btn, isSelected) => { + btn.classList.remove(...PRIMARY_UTILITY_CLASSES); + const icon = btn.querySelector('[part~="value-checkbox-icon"]'); + if (isSelected) { + btn.classList.add('selected'); + btn.part.add('value-checkbox-checked'); + btn.setAttribute('aria-checked', 'true'); + // Atomic 3.60 may leave the tick `display:none` when we only sync parts/classes. + if (icon) { + icon.style.display = 'block'; + icon.style.stroke = '#fff'; + icon.style.color = '#fff'; + } + } else { + btn.classList.remove('selected'); + btn.part.remove('value-checkbox-checked'); + btn.setAttribute('aria-checked', 'false'); + if (icon) { + icon.style.display = 'none'; + icon.style.removeProperty('stroke'); + icon.style.removeProperty('color'); + } + } + }; + + const facetSelectionFingerprint = () => { + const facetSet = searchInterface?.engine?.state?.facetSet || {}; + return Object.entries(facetSet) + .map(([facetId, facetState]) => { + const selected = (facetState.request?.currentValues || []) + .filter((value) => value.state === 'selected') + .map((value) => String(value.value)) + .sort() + .join(','); + return selected ? `${facetId}:${selected}` : ''; + }) + .filter(Boolean) + .sort() + .join('|'); + }; + + const clearFacetCheckboxVisuals = (atomicFacet) => { + const rows = atomicFacet.shadowRoot?.querySelector('[part="values"]')?.querySelectorAll(':scope > li') || []; + rows.forEach((li) => { + const btn = li.querySelector('[part~="value-checkbox"]'); + if (btn) applyCheckboxVisualState(btn, false); + }); + }; + + const endFacetClearInProgress = () => { + facetClearInProgress = false; + clearBaselineFingerprint = ''; + if (facetClearSafetyTimerId) { + clearTimeout(facetClearSafetyTimerId); + facetClearSafetyTimerId = 0; + } + }; + + const maybeEndFacetClearInProgress = () => { + if (!facetClearInProgress) return; + const fingerprint = facetSelectionFingerprint(); + // Idle engine, or user re-selected something different than the clear-time baseline. + if (!fingerprint || fingerprint !== clearBaselineFingerprint) { + endFacetClearInProgress(); + } + }; + + const syncFacetCheckboxVisuals = (atomicFacet) => { + if (facetClearInProgress) { + clearFacetCheckboxVisuals(atomicFacet); + return; + } + + const facetId = atomicFacet.facetId || atomicFacet.getAttribute('field'); + const currentValues = searchInterface?.engine?.state?.facetSet?.[facetId]?.request?.currentValues || []; + const selectedValues = new Set( + currentValues.filter((value) => value.state === 'selected').map((value) => String(value.value).toLowerCase()), + ); + + const rows = atomicFacet.shadowRoot?.querySelector('[part="values"]')?.querySelectorAll(':scope > li') || []; + rows.forEach((li) => { + const btn = li.querySelector('[part~="value-checkbox"]'); + if (!btn) return; + + const candidates = [ + li.dataset.facetRawValue, + li.dataset.contenttype, + li.querySelector('.value-label')?.getAttribute('title'), + ] + .filter(Boolean) + .map((value) => String(value).toLowerCase()); + const isSelected = candidates.some((candidate) => selectedValues.has(candidate)); + applyCheckboxVisualState(btn, isSelected); + }); + }; + const handleAtomicFacetUI = (atomicFacet) => { if (atomicFacet.getAttribute('id') === 'facetStatus') { // Hide the facetStatus if no filters are selected @@ -380,7 +516,12 @@ export default function atomicFacetHandler(block, placeholders, searchInterface) facets.forEach((facet) => { updateFacetUI(facet, atomicFacet, false); }); - sortFacetsInOrder(parentWrapper); + // Only reorder Content Type (parent/child hierarchy). Other facets are already + // alphanumeric from Atomic; reordering all product rows after each search was + // churning Lit DOM and blocking subsequent product checkbox clicks. + if (isContentTypeFacet(atomicFacet)) { + sortFacetsInOrder(parentWrapper); + } const sortedFacets = Array.from(parentWrapper.children); sortedFacets.forEach((facet) => { adjustChildElementsPosition(facet, atomicFacet); @@ -388,6 +529,7 @@ export default function atomicFacetHandler(block, placeholders, searchInterface) // Update parent facet counts with sum of child counts updateParentFacetCounts(parentWrapper); + syncFacetCheckboxVisuals(atomicFacet); const facetParent = atomicFacet.shadowRoot.querySelector('[part="facet"]'); updateChildElementUI(parentWrapper, facetParent); @@ -433,6 +575,8 @@ export default function atomicFacetHandler(block, placeholders, searchInterface) resultTimerId = 0; } resultTimerId = setTimeout(() => { + // Only lift the Clear All gate once engine left the clear-time selection (or went idle). + maybeEndFacetClearInProgress(); const atomicFacets = document.querySelectorAll('atomic-facet'); atomicFacets.forEach((atomicFacet) => { handleAtomicFacetUI(atomicFacet); @@ -460,6 +604,8 @@ export default function atomicFacetHandler(block, placeholders, searchInterface) }, 100); }; + let facetEventListenersBound = false; + const initAtomicFacetUI = (removeSkeleton = false) => { const atomicFacets = document.querySelectorAll('atomic-facet'); atomicFacets.forEach((atomicFacet) => { @@ -476,8 +622,52 @@ export default function atomicFacetHandler(block, placeholders, searchInterface) observeFacetValuesList(atomicFacet); handleAtomicFacetUI(atomicFacet); }); + if (facetEventListenersBound) { + return; + } + facetEventListenersBound = true; + + const onSearchCleared = () => { + // Cancel any pending RESULT_UPDATED sync that would re-apply stale checked chrome. + if (resultTimerId) { + clearTimeout(resultTimerId); + resultTimerId = 0; + } + clearBaselineFingerprint = facetSelectionFingerprint(); + facetClearInProgress = true; + if (facetClearSafetyTimerId) { + clearTimeout(facetClearSafetyTimerId); + } + // Keep forcing clear visuals until engine leaves the clear-time selection. + // Cap retries so a hung clear cannot poll forever. + let clearSafetyAttempts = 0; + const scheduleClearSafetyCheck = () => { + facetClearSafetyTimerId = setTimeout(() => { + maybeEndFacetClearInProgress(); + clearSafetyAttempts += 1; + if (facetClearInProgress && clearSafetyAttempts < 5) { + document.querySelectorAll('atomic-facet').forEach((atomicFacet) => { + clearFacetCheckboxVisuals(atomicFacet); + }); + scheduleClearSafetyCheck(); + return; + } + if (facetClearInProgress) { + // Last resort after ~10s — avoid leaving UI permanently gated. + endFacetClearInProgress(); + } + facetClearSafetyTimerId = 0; + }, 2000); + }; + scheduleClearSafetyCheck(); + document.querySelectorAll('atomic-facet').forEach((atomicFacet) => { + clearFacetCheckboxVisuals(atomicFacet); + }); + }; + document.addEventListener(CUSTOM_EVENTS.RESULT_UPDATED, onResultsUpdate); document.addEventListener(CUSTOM_EVENTS.NO_RESULT_FOUND, onNoResultFoundUpdate); + document.addEventListener(CUSTOM_EVENTS.SEARCH_CLEARED, onSearchCleared); }; const onAtomicFacetUIReady = () => { diff --git a/blocks/atomic-search/components/atomic-search-result.js b/blocks/atomic-search/components/atomic-search-result.js index 661927986f..7afa5e33b0 100644 --- a/blocks/atomic-search/components/atomic-search-result.js +++ b/blocks/atomic-search/components/atomic-search-result.js @@ -585,12 +585,24 @@ export default function atomicResultHandler(block, placeholders) { function onClearBtnClick() { const atomicBreadBox = document.querySelector('atomic-breadbox'); - const coveoClearBtn = atomicBreadBox?.shadowRoot?.querySelector('[part="clear"]'); + // Atomic 3.60 may expose clear as part token list; keep exact + contains match. + const coveoClearBtn = + atomicBreadBox?.shadowRoot?.querySelector('[part="clear"]') || + atomicBreadBox?.shadowRoot?.querySelector('button[part~="clear"]:not([part~="breadcrumb-clear"])'); if (coveoClearBtn) { - const event = new CustomEvent(CUSTOM_EVENTS.SEARCH_CLEARED); - document.dispatchEvent(event); coveoClearBtn.click(); + } else { + // Fallback when breadbox clear control is not in the DOM yet: drop facet hash segments. + const hash = window.location.hash.slice(1); + if (hash) { + const kept = hash + .split('&') + .filter((part) => part && !part.startsWith('f-') && !part.startsWith('ff-') && !part.startsWith('rf-')); + window.location.hash = kept.join('&'); + } } + // Emit after clear starts so facet handlers do not re-sync still-selected engine state. + document.dispatchEvent(new CustomEvent(CUSTOM_EVENTS.SEARCH_CLEARED)); } function decorateExternalLink(link) { diff --git a/blocks/atomic-search/components/atomic-search-template.js b/blocks/atomic-search/components/atomic-search-template.js index eec80d9f98..b4f5847508 100644 --- a/blocks/atomic-search/components/atomic-search-template.js +++ b/blocks/atomic-search/components/atomic-search-template.js @@ -154,6 +154,16 @@ const getCoveoAtomicMarkup = (placeholders) => { transform: scale(0.8); position: absolute; left: 0; + /* Atomic 3.60 defaults to --atomic-primary blue; ExL prod uses black. */ + color: #000; + } + atomic-search-box::part(submit-icon) { + color: #000; + } + /* Atomic 3.60 shows a blue gradient spinner in the search box during facet/sort/pager + refreshes; ExL prod (Atomic 3.13) does not surface a visible loader there. */ + atomic-search-box::part(loading) { + display: none !important; } atomic-search-box::part(suggestions-wrapper) { background-color: var(--background-color); @@ -254,13 +264,13 @@ const getCoveoAtomicMarkup = (placeholders) => { padding-top: 0.625rem; padding-bottom: 0.625rem; } - atomic-facet::part(facet-child-element):hover { - background-color: var(--footer-border-color); - } atomic-facet::part(facet-child-element) { margin-left: 24px; border-radius: 4px; } + atomic-facet::part(facet-child-element):hover { + background-color: var(--footer-border-color); + } atomic-facet::part(only-facet-btn):hover { color: var(--non-spectrum-graphite-gray); } @@ -371,19 +381,44 @@ const getCoveoAtomicMarkup = (placeholders) => { padding: 4px 0; color: var(--non-spectrum-input-text); } - atomic-facet::part(value-checkbox) { + atomic-facet::part(value-checkbox), + atomic-facet::part(value-checkbox):hover, + atomic-facet::part(value-checkbox):focus-visible { border: 2px solid #959595; border-radius: 2px; margin-top: 4px; + /* Kill Atomic 3.60 primary-blue hover/focus flash on the checkbox only. */ + background-color: transparent; + box-shadow: none; + transform: none; + transition: none; } - atomic-facet::part(value-checkbox-label) { + atomic-facet::part(value-checkbox-label), + atomic-facet::part(value-checkbox-label):hover { display: inline; padding-top: 0; padding-bottom: 0; + /* Atomic 3.60 paints a near-white label hover bg that masks child-row grey hover. */ + background-color: transparent; } - atomic-facet::part(value-checkbox-checked) { + atomic-facet::part(facet-child-label), + atomic-facet::part(facet-child-label):hover { + background-color: transparent; + } + atomic-facet::part(value-checkbox-checked), + atomic-facet::part(value-checkbox-checked):hover, + atomic-facet::part(value-checkbox-checked):focus-visible { background-color: var(--non-spectrum-grey-updated); border-color: var(--non-spectrum-grey-updated); + box-shadow: none; + transform: none; + transition: none; + /* Ensure tick uses light stroke on grey fill (Lit Atomic / currentColor). */ + color: #fff; + } + atomic-facet::part(value-checkbox-icon) { + color: #fff; + stroke: #fff; } atomic-facet::part(value-count), atomic-timeframe-facet::part(value-count) { color: var(--non-spectrum-article-dark-gray); @@ -411,15 +446,19 @@ const getCoveoAtomicMarkup = (placeholders) => { atomic-facet::part(values) { max-height: 500px; } + /* Keep Only in layout via opacity to avoid display toggle hover flicker. */ atomic-facet::part(only-facet-btn) { - display: none; + display: flex; + align-items: center; + height: 21px; right: 2px; font-size: var(--spectrum-font-size-50); + opacity: 0; + pointer-events: none; } atomic-facet::part(only-facet-visible) { - display: flex; - align-items: center; - height: 21px; + opacity: 1; + pointer-events: auto; } }