diff --git a/core/tests/Drupal/Nightwatch/Tests/oliveroWideNavExpandTest.js b/core/tests/Drupal/Nightwatch/Tests/oliveroStickyHeaderToggleTest.js similarity index 54% rename from core/tests/Drupal/Nightwatch/Tests/oliveroWideNavExpandTest.js rename to core/tests/Drupal/Nightwatch/Tests/oliveroStickyHeaderToggleTest.js index 5638aa9ce904..02570ffdbc3d 100644 --- a/core/tests/Drupal/Nightwatch/Tests/oliveroWideNavExpandTest.js +++ b/core/tests/Drupal/Nightwatch/Tests/oliveroStickyHeaderToggleTest.js @@ -1,3 +1,6 @@ +const buttonSelector = 'button.sticky-header-toggle'; +const mainMenuSelector = '#block-olivero-main-menu'; + module.exports = { '@tags': ['core', 'olivero'], before(browser) { @@ -18,14 +21,19 @@ module.exports = { '#block-olivero-content h2', 'Congratulations and welcome to the Drupal community!', ) - .assert.not.visible('button.wide-nav-expand') + .assert.not.visible(buttonSelector) .getLocationInView('footer.site-footer', () => { - browser.assert.visible('button.wide-nav-expand'); + browser.assert.visible(buttonSelector); browser.assert.not.visible('#site-header__inner'); }) - .assert.not.visible('#block-olivero-main-menu') - .click('button.wide-nav-expand', () => { - browser.assert.visible('#block-olivero-main-menu'); - }); + .assert.not.visible(mainMenuSelector) + .click(buttonSelector) + .assert.visible(mainMenuSelector) + .assert.attributeEquals(buttonSelector, 'aria-checked', 'true') + + // Sticky header should remain open after page reload in open state. + .drupalRelativeURL('/node') + .assert.visible(mainMenuSelector) + .assert.attributeEquals(buttonSelector, 'aria-checked', 'true'); }, }; diff --git a/core/themes/olivero/css/components/header-sticky-toggle.css b/core/themes/olivero/css/components/header-sticky-toggle.css new file mode 100644 index 000000000000..d0ddd15484a2 --- /dev/null +++ b/core/themes/olivero/css/components/header-sticky-toggle.css @@ -0,0 +1,145 @@ +/* + * DO NOT EDIT THIS FILE. + * See the following change record for more information, + * https://www.drupal.org/node/3084859 + * @preserve + */ + +/** + * @file + * Sticky Header Toggle Button. + * + * This button shows on the left hand side of the header (in LTR layouts), and + * toggles fixing the header to the top of the viewport. + */ + +.sticky-header-toggle { + display: none +} + +@media (min-width: 75rem) { + +.sticky-header-toggle { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 5.625rem; + height: 6.75rem; + pointer-events: none; + opacity: 0; + border: 0; + outline: 0; + background-color: #2494db +} + + .sticky-header-toggle:focus { + cursor: pointer; + pointer-events: auto; + opacity: 1; + outline: solid 2px #fff; + outline-offset: -4px; + } + } + +@media (min-width: 75rem) { + +body:not(.is-always-mobile-nav) .js-fixed .sticky-header-toggle { + visibility: visible +} + } + +@media (min-width: 75rem) { + +body.is-always-mobile-nav .sticky-header-toggle { + visibility: hidden +} + } + +.sticky-header-toggle__icon { + position: relative; + width: 2.25rem; + height: 1.3125rem; + transition: opacity 0.2s; + pointer-events: none; + transform-style: preserve-3d +} + +.sticky-header-toggle__icon > span { + display: block; + height: 0; + /* Intentionally not using CSS logical properties. */ + border-top: solid 3px #fff + } + +[dir="ltr"] .sticky-header-toggle__icon > span:nth-child(1) { + left: 0 +} + +[dir="rtl"] .sticky-header-toggle__icon > span:nth-child(1) { + right: 0 +} + +.sticky-header-toggle__icon > span:nth-child(1) { + position: absolute; + top: 0; + width: 100%; + height: 0; + transition: transform 0.2s; + background-color: #fff; + } + +[dir="ltr"] .sticky-header-toggle__icon > span:nth-child(2) { + left: 0 +} + +[dir="rtl"] .sticky-header-toggle__icon > span:nth-child(2) { + right: 0 +} + +.sticky-header-toggle__icon > span:nth-child(2) { + position: absolute; + top: 0.5625rem; + width: 100%; + height: 0; + transition: opacity 0.2s; + background-color: #fff; + } + +[dir="ltr"] .sticky-header-toggle__icon > span:nth-child(3) { + left: 0 +} + +[dir="rtl"] .sticky-header-toggle__icon > span:nth-child(3) { + right: 0 +} + +.sticky-header-toggle__icon > span:nth-child(3) { + position: absolute; + top: auto; + bottom: 0; + width: 100%; + height: 0; + transition: transform 0.2s; + background-color: #fff; + } + +.js-fixed .sticky-header-toggle { + cursor: pointer; + pointer-events: auto; + opacity: 1; +} + +[aria-checked="true"] .sticky-header-toggle__icon > span:nth-child(1) { + top: 0.5625rem; + transform: rotate(-45deg); + } + +[aria-checked="true"] .sticky-header-toggle__icon > span:nth-child(2) { + opacity: 0; + } + +[aria-checked="true"] .sticky-header-toggle__icon > span:nth-child(3) { + top: 0.5625rem; + transform: rotate(45deg); + } diff --git a/core/themes/olivero/css/components/header-sticky-toggle.pcss.css b/core/themes/olivero/css/components/header-sticky-toggle.pcss.css new file mode 100644 index 000000000000..65114a0b4d8c --- /dev/null +++ b/core/themes/olivero/css/components/header-sticky-toggle.pcss.css @@ -0,0 +1,115 @@ +/** + * @file + * Sticky Header Toggle Button. + * + * This button shows on the left hand side of the header (in LTR layouts), and + * toggles fixing the header to the top of the viewport. + */ + +@import "../base/variables.pcss.css"; + +.sticky-header-toggle { + display: none; + + @media (--nav) { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: var(--content-left); + height: var(--sp6); + pointer-events: none; + opacity: 0; + border: 0; + outline: 0; + background-color: var(--color--blue-50); + + &:focus { + cursor: pointer; + pointer-events: auto; + opacity: 1; + outline: solid 2px var(--color--white); + outline-offset: -4px; + } + } +} + +body:not(.is-always-mobile-nav) .js-fixed .sticky-header-toggle { + @media (--nav) { + visibility: visible; + } +} + +body.is-always-mobile-nav .sticky-header-toggle { + @media (--nav) { + visibility: hidden; + } +} + +.sticky-header-toggle__icon { + position: relative; + width: var(--sp2); + height: 21px; + transition: opacity 0.2s; + pointer-events: none; + transform-style: preserve-3d; + + & > span { + display: block; + height: 0; + /* Intentionally not using CSS logical properties. */ + border-top: solid 3px var(--color--white); + + &:nth-child(1) { + position: absolute; + inset-block-start: 0; + inset-inline-start: 0; + width: 100%; + height: 0; + transition: transform 0.2s; + background-color: var(--color--white); + } + + &:nth-child(2) { + position: absolute; + inset-block-start: 9px; + inset-inline-start: 0; + width: 100%; + height: 0; + transition: opacity 0.2s; + background-color: var(--color--white); + } + + &:nth-child(3) { + position: absolute; + inset-block: auto 0; + inset-inline-start: 0; + width: 100%; + height: 0; + transition: transform 0.2s; + background-color: var(--color--white); + } + } +} + +.js-fixed .sticky-header-toggle { + cursor: pointer; + pointer-events: auto; + opacity: 1; +} + +[aria-checked="true"] .sticky-header-toggle__icon { + & > span:nth-child(1) { + inset-block-start: 9px; + transform: rotate(-45deg); + } + + & > span:nth-child(2) { + opacity: 0; + } + + & > span:nth-child(3) { + inset-block-start: 9px; + transform: rotate(45deg); + } +} diff --git a/core/themes/olivero/css/components/site-header.css b/core/themes/olivero/css/components/site-header.css index b900e7a9bf6c..4c4952d1c382 100644 --- a/core/themes/olivero/css/components/site-header.css +++ b/core/themes/olivero/css/components/site-header.css @@ -81,20 +81,20 @@ @media (min-width: 75rem) { html.js body:not(.is-always-mobile-nav) .site-header__inner { - transition: opacity 0.3s, transform 0.3s + transition: opacity 0.3s, transform 0.3s, box-shadow 0.3s } } @media (min-width: 75rem) { -.site-header__fixable.js-fixed .site-header__inner { +.site-header__fixable.is-expanded .site-header__inner { box-shadow: -36px 1px 36px rgba(0, 0, 0, 0.08) /* LTR */ } } @media (min-width: 75rem) { -[dir="rtl"] .site-header__fixable.js-fixed .site-header__inner { +[dir="rtl"] .site-header__fixable.is-expanded .site-header__inner { box-shadow: 36px 1px 36px rgba(0, 0, 0, 0.08) } } diff --git a/core/themes/olivero/css/components/site-header.pcss.css b/core/themes/olivero/css/components/site-header.pcss.css index adc7b381aa5d..3814461e3268 100644 --- a/core/themes/olivero/css/components/site-header.pcss.css +++ b/core/themes/olivero/css/components/site-header.pcss.css @@ -73,17 +73,17 @@ */ html.js body:not(.is-always-mobile-nav) .site-header__inner { @media (--nav) { - transition: opacity 0.3s, transform 0.3s; + transition: opacity 0.3s, transform 0.3s, box-shadow 0.3s; } } -.site-header__fixable.js-fixed .site-header__inner { +.site-header__fixable.is-expanded .site-header__inner { @media (--nav) { box-shadow: -36px 1px 36px rgba(0, 0, 0, 0.08); /* LTR */ } } -[dir="rtl"] .site-header__fixable.js-fixed .site-header__inner { +[dir="rtl"] .site-header__fixable.is-expanded .site-header__inner { @media (--nav) { box-shadow: 36px 1px 36px rgba(0, 0, 0, 0.08); } diff --git a/core/themes/olivero/js/scripts.es6.js b/core/themes/olivero/js/scripts.es6.js index a485d814a609..5d6f4497ef0e 100644 --- a/core/themes/olivero/js/scripts.es6.js +++ b/core/themes/olivero/js/scripts.es6.js @@ -16,26 +16,75 @@ Drupal.olivero.isDesktopNav = isDesktopNav; - const wideNavButton = document.querySelector('.wide-nav-expand'); + const stickyHeaderToggleButton = document.querySelector( + '.sticky-header-toggle', + ); const siteHeaderFixable = document.querySelector('.site-header__fixable'); - function wideNavIsOpen() { - return wideNavButton.getAttribute('aria-expanded') === 'true'; + function stickyHeaderIsEnabled() { + return stickyHeaderToggleButton.getAttribute('aria-checked') === 'true'; } - function showWideNav() { + /** + * Save the current sticky header expanded state to localStorage, and set + * it to expire after two weeks. + * + * @param {boolean} expandedState - Current state of the sticky header button. + */ + function setStickyHeaderStorage(expandedState) { + const now = new Date(); + + const item = { + value: expandedState, + expiry: now.getTime() + 20160000, // 2 weeks from now. + }; + localStorage.setItem( + 'Drupal.olivero.stickyHeaderState', + JSON.stringify(item), + ); + } + + /** + * Toggle the state of the sticky header between always pinned and + * only pinned when scrolled to the top of the viewport. + * + * @param {boolean} pinnedState - State to change the sticky header to. + */ + function toggleStickyHeaderState(pinnedState) { if (isDesktopNav()) { - wideNavButton.setAttribute('aria-expanded', 'true'); - siteHeaderFixable.classList.add('is-expanded'); + if (pinnedState === true) { + siteHeaderFixable.classList.add('is-expanded'); + } else { + siteHeaderFixable.classList.remove('is-expanded'); + } + + stickyHeaderToggleButton.setAttribute('aria-checked', pinnedState); + setStickyHeaderStorage(pinnedState); } } - // Resets the wide nav button to be closed (its default state). - function hideWideNav() { - if (isDesktopNav()) { - wideNavButton.setAttribute('aria-expanded', 'false'); - siteHeaderFixable.classList.remove('is-expanded'); + /** + * Return the sticky header's stored state from localStorage. + * + * @return {boolean} Stored state of the sticky header. + */ + function getStickyHeaderStorage() { + const stickyHeaderState = localStorage.getItem( + 'Drupal.olivero.stickyHeaderState', + ); + + if (!stickyHeaderState) return null; + + const item = JSON.parse(stickyHeaderState); + const now = new Date(); + + // Compare the expiry time of the item with the current time. + if (now.getTime() > item.expiry) { + // If the item is expired, delete the item from storage and return null. + localStorage.removeItem('Drupal.olivero.stickyHeaderState'); + return null; } + return item.value; } // Only enable scroll effects if the browser supports Intersection Observer. @@ -93,38 +142,27 @@ observer.observe(primaryNav); } - wideNavButton.addEventListener('click', () => { - if (!wideNavIsOpen()) { - showWideNav(); - } else { - hideWideNav(); - } + stickyHeaderToggleButton.addEventListener('click', () => { + toggleStickyHeaderState(!stickyHeaderIsEnabled()); }); - siteHeaderFixable - .querySelector('.site-header__inner') - .addEventListener('focusin', showWideNav); - - // If skip link is clicked, ensure that the wide navigation closes so the header will not be covered up. - document.querySelector('.skip-link').addEventListener('click', hideWideNav); + // If header is pinned open and a header element gains focus, scroll to the + // top of the page to ensure that the header elements can be seen. + document + .querySelector('#site-header__inner') + .addEventListener('focusin', () => { + if (isDesktopNav() && !stickyHeaderIsEnabled()) { + const header = document.querySelector('#header'); + const headerNav = header.querySelector('#header-nav'); + const headerMargin = header.clientHeight - headerNav.clientHeight; + if (window.scrollY > headerMargin) { + window.scrollTo(0, headerMargin); + } + } + }); monitorNavPosition(); + setStickyHeaderStorage(getStickyHeaderStorage()); + toggleStickyHeaderState(getStickyHeaderStorage()); } - - document.addEventListener('keyup', (e) => { - if (e.keyCode === 27) { - // Close the search form. - if ( - 'toggleSearchVisibility' in Drupal.olivero && - 'searchIsVisible' in Drupal.olivero && - Drupal.olivero.searchIsVisible() - ) { - Drupal.olivero.toggleSearchVisibility(false); - } - // Close the wide nav. - else { - hideWideNav(); - } - } - }); })(Drupal); diff --git a/core/themes/olivero/js/scripts.js b/core/themes/olivero/js/scripts.js index 7292acf12a11..be5c288e30f1 100644 --- a/core/themes/olivero/js/scripts.js +++ b/core/themes/olivero/js/scripts.js @@ -14,25 +14,47 @@ } Drupal.olivero.isDesktopNav = isDesktopNav; - var wideNavButton = document.querySelector('.wide-nav-expand'); + var stickyHeaderToggleButton = document.querySelector('.sticky-header-toggle'); var siteHeaderFixable = document.querySelector('.site-header__fixable'); - function wideNavIsOpen() { - return wideNavButton.getAttribute('aria-expanded') === 'true'; + function stickyHeaderIsEnabled() { + return stickyHeaderToggleButton.getAttribute('aria-checked') === 'true'; } - function showWideNav() { + function setStickyHeaderStorage(expandedState) { + var now = new Date(); + var item = { + value: expandedState, + expiry: now.getTime() + 20160000 + }; + localStorage.setItem('Drupal.olivero.stickyHeaderState', JSON.stringify(item)); + } + + function toggleStickyHeaderState(pinnedState) { if (isDesktopNav()) { - wideNavButton.setAttribute('aria-expanded', 'true'); - siteHeaderFixable.classList.add('is-expanded'); + if (pinnedState === true) { + siteHeaderFixable.classList.add('is-expanded'); + } else { + siteHeaderFixable.classList.remove('is-expanded'); + } + + stickyHeaderToggleButton.setAttribute('aria-checked', pinnedState); + setStickyHeaderStorage(pinnedState); } } - function hideWideNav() { - if (isDesktopNav()) { - wideNavButton.setAttribute('aria-expanded', 'false'); - siteHeaderFixable.classList.remove('is-expanded'); + function getStickyHeaderStorage() { + var stickyHeaderState = localStorage.getItem('Drupal.olivero.stickyHeaderState'); + if (!stickyHeaderState) return null; + var item = JSON.parse(stickyHeaderState); + var now = new Date(); + + if (now.getTime() > item.expiry) { + localStorage.removeItem('Drupal.olivero.stickyHeaderState'); + return null; } + + return item.value; } if ('IntersectionObserver' in window && 'IntersectionObserverEntry' in window && 'intersectionRatio' in window.IntersectionObserverEntry.prototype) { @@ -79,25 +101,22 @@ observer.observe(primaryNav); } - wideNavButton.addEventListener('click', function () { - if (!wideNavIsOpen()) { - showWideNav(); - } else { - hideWideNav(); + stickyHeaderToggleButton.addEventListener('click', function () { + toggleStickyHeaderState(!stickyHeaderIsEnabled()); + }); + document.querySelector('#site-header__inner').addEventListener('focusin', function () { + if (isDesktopNav() && !stickyHeaderIsEnabled()) { + var header = document.querySelector('#header'); + var headerNav = header.querySelector('#header-nav'); + var headerMargin = header.clientHeight - headerNav.clientHeight; + + if (window.scrollY > headerMargin) { + window.scrollTo(0, headerMargin); + } } }); - siteHeaderFixable.querySelector('.site-header__inner').addEventListener('focusin', showWideNav); - document.querySelector('.skip-link').addEventListener('click', hideWideNav); monitorNavPosition(); + setStickyHeaderStorage(getStickyHeaderStorage()); + toggleStickyHeaderState(getStickyHeaderStorage()); } - - document.addEventListener('keyup', function (e) { - if (e.keyCode === 27) { - if ('toggleSearchVisibility' in Drupal.olivero && 'searchIsVisible' in Drupal.olivero && Drupal.olivero.searchIsVisible()) { - Drupal.olivero.toggleSearchVisibility(false); - } else { - hideWideNav(); - } - } - }); })(Drupal); \ No newline at end of file diff --git a/core/themes/olivero/olivero.libraries.yml b/core/themes/olivero/olivero.libraries.yml index 8734ec4ca8c8..6613b1b2114d 100644 --- a/core/themes/olivero/olivero.libraries.yml +++ b/core/themes/olivero/olivero.libraries.yml @@ -36,11 +36,11 @@ global-styling: css/components/header-buttons-mobile.css: {} css/components/header-navigation.css: {} css/components/header-site-branding.css: {} + css/components/header-sticky-toggle.css: {} css/components/hero.css: {} css/components/links.css: {} css/components/messages.css: {} css/components/navigation/nav-button-mobile.css: {} - css/components/navigation/wide-nav-expand.css: {} css/components/navigation/nav-primary-button.css: {} css/components/navigation/nav-primary.css: {} css/components/navigation/nav-primary-wide.css: {} diff --git a/core/themes/olivero/templates/layout/page.html.twig b/core/themes/olivero/templates/layout/page.html.twig index 00c399a69355..5d5b1caa2b46 100644 --- a/core/themes/olivero/templates/layout/page.html.twig +++ b/core/themes/olivero/templates/layout/page.html.twig @@ -52,11 +52,11 @@ {% if page.header or page.primary_menu or page.secondary_menu %}