diff --git a/.gitignore b/.gitignore index 31e32601..84719a2f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ yarn-error.log* lerna-debug.log* .pnpm-debug.log* +# macOS +.DS_Store + # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json diff --git a/website/modules/asset/ui/src/index.js b/website/modules/asset/ui/src/index.js index c1b76b97..254994f0 100644 --- a/website/modules/asset/ui/src/index.js +++ b/website/modules/asset/ui/src/index.js @@ -7,10 +7,15 @@ import { initCaseStudiesFilterHandler } from './initCaseStudiesFilterHandler'; import { initFormValidation } from './js/formValidation'; import { initPhoneFormatting } from './js/phoneFormat'; import { initSmoothCounters } from './smoothCounters'; -import lozad from 'lozad'; +import { initFontChanger } from './initFontChanger'; +import { initImageLozad } from './initImageLozad'; import { setupTagSearchForInput } from './searchInputHandler'; -import { FilterModal } from './filterModal'; import { initClientSideFiltering } from './clientSideFiltering'; +import { + saveScrollPosition, + getSavedScrollPosition, + clearSavedScrollPosition, +} from './scrollMemory'; function revealLoaded() { document @@ -42,38 +47,6 @@ function initConfiguration() { } } -function initImageLozad() { - const observer = lozad(); - observer.observe(); -} - -function initFontChanger() { - const heroContent = document.querySelector('.sf-hero-content strong'); - if (!heroContent) return; - - const fonts = [ - 'Poppins', - 'Philosopher', - 'Pinyon Script', - 'Racing Sans One', - 'Poiret One', - 'Redacted Script', - 'Redressed', - 'Rock 3D', - 'Rubik Glitch Pop', - 'Yesteryear', - 'Roboto Mono', - 'Pixelify Sans', - ]; - let currentFontIndex = 0; - - setInterval(() => { - currentFontIndex = (currentFontIndex + 1) % fonts.length; - const currentFont = fonts.at(currentFontIndex); - heroContent.style.fontFamily = currentFont; - }, 500); -} - function initCaseStudiesTagFilter({ inputSelector = '.tag-search', containerSelector = '.filter-section', @@ -110,7 +83,14 @@ function initBarbaPageTransitions() { const originalEnterCallback = function (data, hasFilterAnchor) { if (!hasFilterAnchor) { - window.scrollTo(0, 0); + const nextUrl = data.next.url.href; + const savedScroll = getSavedScrollPosition(nextUrl); + if (savedScroll === null) { + window.scrollTo(0, 0); + } else { + // Restore the scroll position when returning to the cases listing. + window.scrollTo(0, savedScroll); + } } const menuButton = document.getElementById('nav-icon'); @@ -147,8 +127,20 @@ function initBarbaPageTransitions() { }; // Initialize Apostrophe forms (already inside apos.util.onReady) - if (!initializeApostropheForm(data.next.container)) { + const willReload = !initializeApostropheForm(data.next.container); + + if (willReload) { + /* + * A full reload is about to happen. Do NOT clear the saved scroll + * position here; the load-time restore relies on it surviving the reload. + */ window.location.reload(); + } else { + /* + * SPA transition: the saved scroll position (if any) has been applied + * above, so it is safe to clear now. + */ + clearSavedScrollPosition(); } // Remove the previous page container to avoid blinking @@ -178,6 +170,11 @@ function initBarbaPageTransitions() { ], }); + barba.hooks.beforeLeave((data) => { + // Remember scroll position of the cases listing before navigating away. + saveScrollPosition(data.current.url.href); + }); + barba.hooks.after(() => { // Update menu active state const currentPath = window.location.pathname; @@ -192,9 +189,6 @@ function initBarbaPageTransitions() { }); revealLoaded(); - if (!window.caseStudiesFilterModal) { - initFilterModal(); - } }); }); } @@ -240,28 +234,6 @@ function initMenuToggle() { }); } -function initFilterModal() { - if (!document.querySelector('.cs_list')) { - return; - } - - window.caseStudiesFilterModal = new FilterModal({ - modalSelector: '#filter-modal', - openBtnSelector: '.filters-cta', - closeBtnSelector: '.filter-modal__button', - backdropSelector: '.filter-modal__backdrop', - clearAllSelector: '.clear-all', - selectedTagsSelector: '.selected-tags', - tagsFilterSelector: '.tags-filter', - }); -} - -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initFilterModal); -} else { - initFilterModal(); -} - export default () => { initConfiguration(); @@ -281,7 +253,30 @@ export default () => { initAnchorNavigation(); initMenuToggle(); + /* + * Restore scroll position when returning to the /cases listing after a full + * page reload (Barba reloads pages without an Apostrophe form). + */ + const restoredScroll = getSavedScrollPosition(window.location.href); + if (restoredScroll !== null) { + clearSavedScrollPosition(); + /* + * Reassert the position over a short window to counter layout shifts from + * lazily loaded images/content. + */ + const reapply = (attempts) => { + window.scrollTo(0, restoredScroll); + if (attempts > 0) { + setTimeout(() => reapply(attempts - 1), 100); + } + }; + reapply(5); + } + setTimeout(() => { + // Do not hijack scroll if we are restoring a saved listing position. + if (restoredScroll !== null) return; + const { pathname, search, hash } = window.location; if ( pathname.includes('/cases') && diff --git a/website/modules/asset/ui/src/initFilterModal.js b/website/modules/asset/ui/src/initFilterModal.js new file mode 100644 index 00000000..079ba8cf --- /dev/null +++ b/website/modules/asset/ui/src/initFilterModal.js @@ -0,0 +1,25 @@ +import { FilterModal } from './filterModal'; + +const initFilterModal = function () { + if (!document.querySelector('.cs_list')) { + return; + } + + window.caseStudiesFilterModal = new FilterModal({ + modalSelector: '#filter-modal', + openBtnSelector: '.filters-cta', + closeBtnSelector: '.filter-modal__button', + backdropSelector: '.filter-modal__backdrop', + clearAllSelector: '.clear-all', + selectedTagsSelector: '.selected-tags', + tagsFilterSelector: '.tags-filter', + }); +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initFilterModal); +} else { + initFilterModal(); +} + +export { initFilterModal }; diff --git a/website/modules/asset/ui/src/initFontChanger.js b/website/modules/asset/ui/src/initFontChanger.js new file mode 100644 index 00000000..8e16a513 --- /dev/null +++ b/website/modules/asset/ui/src/initFontChanger.js @@ -0,0 +1,28 @@ +const initFontChanger = function () { + const heroContent = document.querySelector('.sf-hero-content strong'); + if (!heroContent) return; + + const fonts = [ + 'Poppins', + 'Philosopher', + 'Pinyon Script', + 'Racing Sans One', + 'Poiret One', + 'Redacted Script', + 'Redressed', + 'Rock 3D', + 'Rubik Glitch Pop', + 'Yesteryear', + 'Roboto Mono', + 'Pixelify Sans', + ]; + let currentFontIndex = 0; + + setInterval(() => { + currentFontIndex = (currentFontIndex + 1) % fonts.length; + const currentFont = fonts.at(currentFontIndex); + heroContent.style.fontFamily = currentFont; + }, 500); +}; + +export { initFontChanger }; diff --git a/website/modules/asset/ui/src/initImageLozad.js b/website/modules/asset/ui/src/initImageLozad.js new file mode 100644 index 00000000..f7f126fc --- /dev/null +++ b/website/modules/asset/ui/src/initImageLozad.js @@ -0,0 +1,8 @@ +import lozad from 'lozad'; + +const initImageLozad = function () { + const observer = lozad(); + observer.observe(); +}; + +export { initImageLozad }; diff --git a/website/modules/asset/ui/src/scrollMemory.js b/website/modules/asset/ui/src/scrollMemory.js new file mode 100644 index 00000000..1aba5328 --- /dev/null +++ b/website/modules/asset/ui/src/scrollMemory.js @@ -0,0 +1,75 @@ +/* + * Scroll memory for the case studies index page. + * Preserves the user's scroll position when navigating from a case study + * show page back to the /cases listing. + */ + +const CASES_PATH = '/cases'; +/* + * Use sessionStorage so the position survives full page reloads. The Barba + * enter callback triggers window.location.reload() for pages without an + * Apostrophe form (which includes the /cases listing), so an in-memory store + * would be wiped before we could restore. + */ +const STORAGE_KEY = 'casesScrollPosition'; + +const isCasesListing = function (pathname) { + return pathname === CASES_PATH; +}; + +// Save current scroll position for a given URL if it is the cases listing. +const saveScrollPosition = function (url) { + try { + const { pathname } = new URL(url, window.location.origin); + if (isCasesListing(pathname)) { + const position = window.scrollY || window.pageYOffset || 0; + window.sessionStorage.setItem(STORAGE_KEY, String(position)); + } + } catch (error) { + // Ignore malformed URLs / unavailable storage + // eslint-disable-next-line no-console + console.error('Failed to save scroll position:', error); + } +}; + +// Returns the stored scroll position for a URL, or null if none exists. +const getSavedScrollPosition = function (url) { + try { + const { pathname } = new URL(url, window.location.origin); + if (!isCasesListing(pathname)) { + return null; + } + const stored = window.sessionStorage.getItem(STORAGE_KEY); + if (stored === null) { + return null; + } + const parsed = parseInt(stored, 10); + if (isNaN(parsed)) { + return null; + } + return parsed; + } catch (error) { + // Ignore malformed URLs / unavailable storage + // eslint-disable-next-line no-console + console.error('Failed to get saved scroll position:', error); + return null; + } +}; + +// Clears the stored scroll position. +const clearSavedScrollPosition = function () { + try { + window.sessionStorage.removeItem(STORAGE_KEY); + } catch (error) { + // Ignore unavailable storage + // eslint-disable-next-line no-console + console.error('Failed to clear saved scroll position:', error); + } +}; + +export { + saveScrollPosition, + getSavedScrollPosition, + clearSavedScrollPosition, + isCasesListing, +}; diff --git a/website/modules/asset/ui/src/scss/_cases.scss b/website/modules/asset/ui/src/scss/_cases.scss index 6c8a4358..22b6ba17 100644 --- a/website/modules/asset/ui/src/scss/_cases.scss +++ b/website/modules/asset/ui/src/scss/_cases.scss @@ -953,7 +953,7 @@ @include breakpoint-medium { margin: 40px 0 120px; - height: 600px; + height: auto; } &-card { @@ -973,6 +973,7 @@ max-height: 396px; max-width: none; position: absolute; + top: 0; width: 100%; z-index: 0; @@ -997,9 +998,9 @@ @include breakpoint-medium { background: $white; flex-direction: row; - margin-top: 0; + margin-top: 40px; margin-left: 20.76%; - height: 520px; + height: auto; width: 79.24%; } @@ -1166,7 +1167,6 @@ border-top: none; padding: 16px 20px; margin: 0; - overflow-y: auto; } @include breakpoint-large { diff --git a/website/public/js/modules/case-studies-page/search-handler.js b/website/public/js/modules/case-studies-page/search-handler.js index d6cb8d29..c0fdac49 100644 --- a/website/public/js/modules/case-studies-page/search-handler.js +++ b/website/public/js/modules/case-studies-page/search-handler.js @@ -19,6 +19,115 @@ partner: new Set() }; + // sessionStorage keys for preserving the filter state when navigating to a + // case study show page and back to the /cases listing. + const FILTER_STORAGE_KEY = 'casesFilterState'; + const FILTER_RETURN_KEY = 'casesFilterReturn'; + + // Serialize the current filter state and search term. + function getFilterStateSnapshot() { + return { + industry: Array.from(filterState.industry), + stack: Array.from(filterState.stack), + caseStudyType: Array.from(filterState.caseStudyType), + partner: Array.from(filterState.partner), + search: searchTerm || '' + }; + } + + // Persist the current filter state to sessionStorage. + function persistFilterState() { + try { + window.sessionStorage.setItem( + FILTER_STORAGE_KEY, + JSON.stringify(getFilterStateSnapshot()) + ); + } catch (error) { + // Ignore unavailable storage + } + } + + // Remove any persisted filter state. + function clearPersistedFilterState() { + try { + window.sessionStorage.removeItem(FILTER_STORAGE_KEY); + } catch (error) { + // Ignore unavailable storage + } + } + + // Open the filter categories that have active selections after a restore. + function openCategoriesForActiveFilters() { + Object.keys(filterState).forEach(function (filterType) { + if (filterState[filterType].size === 0) { + return; + } + const checkbox = document.getElementById('filter-toggle-' + filterType); + if (checkbox && !checkbox.checked) { + checkbox.checked = true; + const button = document.querySelector( + 'label[for="filter-toggle-' + filterType + '"]' + ); + if (button) { + button.setAttribute('aria-expanded', 'true'); + } + } + }); + } + + // Restore the persisted filter state (tags + search) into the DOM. + function restoreFilterState() { + let saved = null; + try { + saved = JSON.parse(window.sessionStorage.getItem(FILTER_STORAGE_KEY)); + } catch (error) { + saved = null; + } + if (!saved) { + return false; + } + + Object.keys(filterState).forEach(function (filterType) { + filterState[filterType].clear(); + const values = Array.isArray(saved[filterType]) ? saved[filterType] : []; + values.forEach(function (value) { + filterState[filterType].add(value); + updateTagActiveState(filterType, value, true); + }); + }); + + searchTerm = saved.search || ''; + const searchInput = document.getElementById('case-studies-search'); + if (searchInput) { + searchInput.value = searchTerm; + } + + openCategoriesForActiveFilters(); + return true; + } + + // Mark intent to restore filters and persist them when the user opens a case + // study from the listing. This is the precise "leaving /cases -> case study" + // moment; the flag is consumed when the /cases page next loads. + function setupReturnIntentOnCardClick() { + const grid = document.getElementById('case-studies-grid'); + if (!grid) { + return; + } + grid.addEventListener('click', function (event) { + const card = event.target.closest('.cs_card'); + if (!card) { + return; + } + persistFilterState(); + try { + window.sessionStorage.setItem(FILTER_RETURN_KEY, '1'); + } catch (error) { + // Ignore unavailable storage + } + }); + } + // Populate filterState from server-rendered active tag items on page load function initFilterState() { Object.keys(filterState).forEach(function (filterType) { @@ -396,10 +505,36 @@ clearAll.addEventListener('click', handleClearAllClick); } + // Persist filters and mark restore intent when opening a case study card + setupReturnIntentOnCardClick(); + // Initialize in-memory filter state and search term from server-rendered values initFilterState(); searchTerm = searchInput.value; + // If the user is returning from a case study show page, restore the + // previously selected filters; otherwise start fresh and drop stored state. + let shouldRestore = false; + try { + shouldRestore = + window.sessionStorage.getItem(FILTER_RETURN_KEY) === '1'; + window.sessionStorage.removeItem(FILTER_RETURN_KEY); + } catch (error) { + shouldRestore = false; + } + + if (shouldRestore) { + restoreFilterState(); + clearPersistedFilterState(); + } else { + clearPersistedFilterState(); + } + + // Sync clear-button visibility with the (possibly restored) search value + if (clearButton) { + updateClearButtonVisibility(searchInput, clearButton); + } + // Initialize selected tags list, card filtering, and tag counts on page load updateSelectedTagsList(); applyCardFiltering();