diff --git a/assets/js/components-noncritical.js b/assets/js/components-noncritical.js index 38724b121..04985734b 100644 --- a/assets/js/components-noncritical.js +++ b/assets/js/components-noncritical.js @@ -66,23 +66,31 @@ const componentConfiguration = [ Component: PageHeader, selector: '.site-header', options: { + // Page structure pageWrapper: 'html', + siteHeader: '.site-header', + + // Navigation + mainNav: '.main-navigation', + navigationList: 'ul.navbar-nav', navToggle: '.mainnav-toggle', - searchToggle: '.searchbox-toggle', - headerSearch: '.header-search', - searchInput: 'input.search-query', - mainNav: '.main-navigation ul.navbar-nav', - menuLevel1: '.menu_level_1', navActiveClass: 'mainnav-active', - searchboxActiveClass: 'searchbox-active', - submenuTriggerElement: 'i', + languageSelector: '.language-selector', + + // Submenus + menuLevel1: '.menu_level_1', + submenuTriggerElement: 'button', submenuTriggerClass: 'submenu-trigger', submenuDataParam: 'submenu', submenuActiveClass: 'submenu-active', - navigationList: 'ul.nav.navbar-nav', + disableSubmenuTriggers: '.no-triggers', + + // Search + searchToggle: '.searchbox-toggle', + headerSearch: '.header-search', + searchInput: 'input.search-query', + searchboxActiveClass: 'searchbox-active', filledClass: 'filled', - languageSelector: '.site-header .language-selector', - stickyHeader: '.site-header-sticky', }, }, { diff --git a/assets/js/components/PageHeader.component.js b/assets/js/components/PageHeader.component.js index 44737367f..fb0a44a17 100644 --- a/assets/js/components/PageHeader.component.js +++ b/assets/js/components/PageHeader.component.js @@ -4,158 +4,260 @@ export default class PageHeader { constructor(_, options) { this.options = options; + // Page structure this.pageWrapper = document.querySelector(options.pageWrapper); + this.siteHeader = document.querySelector(options.siteHeader); + + // Navigation + this.mainNav = document.querySelector(options.mainNav); + this.navigationList = document.querySelectorAll(options.navigationList); this.navToggle = document.querySelector(options.navToggle); + this.languageSelector = this.siteHeader?.querySelector(options.languageSelector) ?? null; + + // Search this.searchToggle = document.querySelector(options.searchToggle); this.headerSearch = document.querySelector(options.headerSearch); this.searchInput = this.headerSearch?.querySelector(options.searchInput) ?? null; - this.mainNav = document.querySelector(options.mainNav); - this.level1Menus = []; - this.submenuTriggerElements = []; - this.languageSelector = document.querySelector(options.languageSelector); - this.stickyHeader = document.querySelector(options.stickyHeader); + + // Internal state + this.submenuRefs = []; + this.submenuTriggerSelector = '.' + options.submenuTriggerClass; this.init(); } init() { - this.setActiveStateOnMenuItems(); this.navToggleSetup(); this.searchToggleSetup(); this.headerSearchSetup(); - this.addSubmenuTriggers(); + this.submenuTriggersSetup(); + this.setActiveStateOnMenuItems(); // Must run AFTER submenuTriggersSetup so [data-submenu] exists this.languageSelectorSetup(); - this.stickyHeaderSetup(); + this.headerScrollSetup(); + } + + isMobile() { + return window.innerWidth < 992; // Has to be in sync with the SCSS variables $collapse-nav and $grid-breakpoints } navToggleSetup() { - if (this.navToggle === null) { - return; + if (!this.navToggle) return; + + if (this.isMobile() && this.mainNav) { + this.mainNav.setAttribute('aria-hidden', 'true'); } - let scrollTop = 0; + let savedScrollTop = 0; this.navToggle.addEventListener('click', (event) => { event.preventDefault(); - const ariaExpanded = this.navToggle.getAttribute('aria-expanded') === 'true'; + const isOpening = !this.pageWrapper.classList.contains(this.options.navActiveClass); - this.navToggle.setAttribute('aria-expanded', ariaExpanded); - - if (!this.pageWrapper.classList.contains(this.options.navActiveClass)) { - scrollTop = window.scrollY; // set scroll position intro variable - this.changePageClasses({ - add: this.options.navActiveClass, - remove: this.options.searchboxActiveClass, - }); + if (isOpening) { + savedScrollTop = window.scrollY; + document.body.style.top = `-${savedScrollTop}px`; + this.changePageClasses({ add: this.options.navActiveClass, remove: this.options.searchboxActiveClass }); } else { + document.body.style.top = ''; this.changePageClasses({ remove: this.options.navActiveClass }); - window.scrollTo({ top: scrollTop, left: 0, behavior: 'instant' }); // scroll to saved position + window.scrollTo({ top: savedScrollTop, left: 0, behavior: 'instant' }); } + + this.navToggle.setAttribute('aria-expanded', isOpening); + this.mainNav?.setAttribute('aria-hidden', !isOpening); }); } searchToggleSetup() { - if (this.searchToggle === null) { - return; - } + if (!this.searchToggle) return; this.searchToggle.addEventListener('click', (event) => { event.preventDefault(); + this.changePageClasses({ toggle: this.options.searchboxActiveClass, remove: this.options.navActiveClass }); - this.changePageClasses({ - toggle: this.options.searchboxActiveClass, - remove: this.options.navActiveClass, - }); - - const ariaExpanded = this.searchToggle.getAttribute('aria-expanded') === 'true'; - this.searchToggle.setAttribute('aria-expanded', !ariaExpanded); + const isExpanded = this.searchToggle.getAttribute('aria-expanded') === 'true'; + this.searchToggle.setAttribute('aria-expanded', !isExpanded); this.searchInput?.focus(); }); } headerSearchSetup() { - if (this.headerSearch === null) { - return; - } + if (!this.headerSearch) return; this.headerSearch.addEventListener('blur', () => { this.changePageClasses({ remove: this.options.searchboxActiveClass }); }); - this.headerSearch.addEventListener('click', (event) => { - if (this.headerSearch.contains(event.target)) { - return; + this.headerSearch.addEventListener('input', () => { + this.headerSearch.classList.toggle(this.options.filledClass, this.searchInput.value !== ''); + }); + + document.addEventListener('click', (e) => { + if (!this.headerSearch.contains(e.target) && !this.searchToggle?.contains(e.target)) { + this.changePageClasses({ remove: this.options.searchboxActiveClass }); } + }); + } - this.changePageClasses({ remove: this.options.searchboxActiveClass }); + submenuTriggersSetup() { + if (this.navigationList.length === 0) return; + + this.navigationList.forEach((navigation) => { + const submenus = navigation.querySelectorAll(this.options.menuLevel1); + submenus.forEach((submenu, index) => this.initSubmenu(submenu, index)); }); - this.headerSearch.addEventListener('input', () => { - if (this.searchInput.value !== '') { - this.headerSearch.classList.add(this.options.filledClass); + if (this.submenuRefs.length === 0) return; - return; + // Single delegated click handler for all submenu interactions + document.addEventListener('click', (e) => this.handleDocumentClick(e)); + window.addEventListener('keyup', (e) => e.key === 'Escape' && this.closeAllSubmenus()); + } + + initSubmenu(submenu, index) { + const submenuParent = submenu.parentElement; + if (!submenuParent) return; + + submenuParent.dataset[this.options.submenuDataParam] = true; + + // Check if submenu triggers should be disabled (e.g. in footer where items are listed directly) + const disableSelector = this.options.disableSubmenuTriggers; + if (disableSelector && submenuParent.closest(disableSelector)) { + const disabledTrigger = submenuParent.querySelector(this.submenuTriggerSelector); + if (disabledTrigger) { + disabledTrigger.setAttribute('tabindex', '-1'); + disabledTrigger.style.setProperty('pointer-events', 'none'); + disabledTrigger.removeAttribute('aria-haspopup'); + disabledTrigger.removeAttribute('aria-expanded'); + disabledTrigger.removeAttribute('aria-controls'); } + submenu.removeAttribute('aria-hidden'); + return; + } - this.headerSearch.classList.remove(this.options.filledClass); - }); + // Get or create trigger + let submenuTrigger = submenuParent.querySelector(this.submenuTriggerSelector); + if (!submenuTrigger) { + submenuTrigger = this.createSubmenuTrigger(submenuParent, submenu); + } + + // Generate submenu ID for ARIA + if (!submenu.id) { + const locationId = submenuParent.dataset.locationId; + submenu.id = locationId ? `submenu-${locationId}` : `submenu-${index}`; + } + submenu.setAttribute('aria-hidden', 'true'); + + // Ensure ARIA attributes + submenuTrigger.setAttribute('aria-haspopup', 'menu'); + submenuTrigger.setAttribute('aria-expanded', 'false'); + submenuTrigger.setAttribute('aria-controls', submenu.id); + + this.submenuRefs.push({ submenuParent, submenuTrigger, submenu }); } - addSubmenuTriggers() { - if (this.mainNav === null) { - return; + createSubmenuTrigger(submenuParent, submenu) { + const trigger = document.createElement(this.options.submenuTriggerElement); + trigger.classList.add(this.options.submenuTriggerClass); + + const parentLink = submenuParent.querySelector('a'); + if (parentLink) { + trigger.setAttribute('aria-label', `${parentLink.textContent.trim()} menu`); } - this.level1Menus = this.mainNav.querySelectorAll(this.options.menuLevel1); - if (this.level1Menus.length === 0) { + submenuParent.insertBefore(trigger, submenu); + return trigger; + } + + handleDocumentClick(e) { + const clickedTrigger = e.target.closest(this.submenuTriggerSelector); + + if (clickedTrigger) { + const ref = this.submenuRefs.find(({ submenuTrigger }) => submenuTrigger === clickedTrigger); + if (ref) { + this.toggleSubmenu(ref); + } return; } - this.level1Menus.forEach((menu) => { - const submenuTriggerContent = document.createElement(this.options.submenuTriggerElement); - submenuTriggerContent.classList.add(this.options.submenuTriggerClass); - - menu.parentElement.insertBefore(submenuTriggerContent, menu); - menu.parentElement.dataset[this.options.submenuDataParam] = true; + // Click outside - close submenus on desktop only + if (this.isMobile()) return; - this.submenuTriggerElements.push(submenuTriggerContent); + this.submenuRefs.forEach((ref) => { + if (!ref.submenuParent.contains(e.target) && ref.submenuParent.classList.contains(this.options.submenuActiveClass)) { + this.closeSubmenu(ref); + } }); + } + + toggleSubmenu(ref) { + const { submenuParent, submenuTrigger, submenu } = ref; + + this.setSubmenuMaxHeight(submenu); + const isActive = submenuParent.classList.toggle(this.options.submenuActiveClass); - this.submenuTriggerElements.forEach((submenuTrigger) => { - submenuTrigger.addEventListener('click', () => { - this.toggleMobileSubmenu(submenuTrigger); + // Close other submenus when opening one + if (isActive) { + this.submenuRefs.forEach((other) => { + if (other !== ref && other.submenuParent.classList.contains(this.options.submenuActiveClass)) { + this.closeSubmenu(other); + } }); + } + + submenuTrigger.setAttribute('aria-expanded', isActive); + submenu.setAttribute('aria-hidden', !isActive); + } + + closeSubmenu({ submenuParent, submenuTrigger, submenu }) { + submenuParent.classList.remove(this.options.submenuActiveClass); + submenuTrigger.setAttribute('aria-expanded', 'false'); + submenu.setAttribute('aria-hidden', 'true'); + } + + closeAllSubmenus() { + this.submenuRefs.forEach((ref) => { + if (ref.submenuParent.classList.contains(this.options.submenuActiveClass)) { + this.closeSubmenu(ref); + } }); } - toggleMobileSubmenu(submenuTrigger) { - submenuTrigger.parentElement.classList.toggle(this.options.submenuActiveClass); + setSubmenuMaxHeight(submenu) { + submenu.style.setProperty('--max-height', `${submenu.scrollHeight}px`); } setActiveStateOnMenuItems() { - if (page.dataset.path === undefined) { - return; - } + if (!page.dataset.path) return; + + const activeItemIds = JSON.parse(page.dataset.path); + + this.navigationList.forEach((navigation) => { + activeItemIds.forEach((id) => { + const item = navigation.querySelector(`[data-location-id="${id}"]`); + if (!item) return; - const activeItemsList = JSON.parse(page.dataset.path); - const navigationList = document.querySelectorAll(this.options.navigationList); + item.classList.add('active'); - navigationList.forEach((navigation) => { - activeItemsList.forEach((activeItemId) => { - const item = navigation.querySelector(`[data-location-id="${activeItemId}"]`); + // Pre-expand parent submenu on mobile only + if (!this.isMobile()) return; - if (item !== null) { - item.classList.add('active', this.options.submenuActiveClass); + const parentWithSubmenu = item.parentElement?.closest('li[data-submenu]'); + const ref = this.submenuRefs.find((r) => r.submenuParent === parentWithSubmenu); + if (ref) { + ref.submenuParent.classList.add(this.options.submenuActiveClass); + ref.submenuTrigger.setAttribute('aria-expanded', 'true'); + ref.submenu.setAttribute('aria-hidden', 'false'); + this.setSubmenuMaxHeight(ref.submenu); } }); }); } languageSelectorSetup() { - if (this.languageSelector === null) { - return; - } + if (!this.languageSelector) return; + this.languageSelector.addEventListener('show.bs.dropdown', () => { this.removePageClass(this.options.navActiveClass); this.removePageClass(this.options.searchboxActiveClass); @@ -188,16 +290,15 @@ export default class PageHeader { this.pageWrapper.classList.toggle(classToToggle); } - stickyHeaderSetup() { - if (this.stickyHeader === null) { - return; - } - ['load', 'scroll', 'resize', 'orientationchange'].forEach((eventType) => { - window.addEventListener(eventType, () => { - window.scrollY >= 1 - ? this.stickyHeader.classList.add('site-header-sticky--active') - : this.stickyHeader.classList.remove('site-header-sticky--active'); - }); + headerScrollSetup() { + if (!this.siteHeader) return; + + const updateScrollState = () => { + this.siteHeader.classList.toggle('scrolled', window.scrollY >= 1); + }; + + ['load', 'scroll', 'resize', 'orientationchange'].forEach((event) => { + window.addEventListener(event, updateScrollState); }); } } diff --git a/assets/sass/_accessibility.scss b/assets/sass/_accessibility.scss index 184fff2bf..3f5082b0c 100644 --- a/assets/sass/_accessibility.scss +++ b/assets/sass/_accessibility.scss @@ -49,8 +49,3 @@ button { @include custom-outline; } } - -.sr-only { - position: absolute; - left: -9999px; -} diff --git a/assets/sass/_globals.scss b/assets/sass/_globals.scss index 2446bdf06..31543ee01 100644 --- a/assets/sass/_globals.scss +++ b/assets/sass/_globals.scss @@ -13,7 +13,7 @@ body { // Global styles body:has(.site-header-fixed), -body:has(.site-header-sticky--active) { +body:has(.site-header-sticky.scrolled) { padding-top: var(--header-height); } diff --git a/assets/sass/layout/_footer.scss b/assets/sass/layout/_footer.scss index 15fc9178a..046178644 100644 --- a/assets/sass/layout/_footer.scss +++ b/assets/sass/layout/_footer.scss @@ -22,10 +22,21 @@ justify-content: center; margin: 0 0 1.5rem; padding: 0; - a { + a, + span, + .submenu-trigger { @include text-link; padding: .5em 1.1428571429em; } + a, + span { + display: inline-block; + } + .menu_level_1 { + list-style-type: none; + padding: .5rem 0; + margin: 0; + } } } @@ -76,9 +87,6 @@ } } } - .tt { - display: none; - } } } diff --git a/assets/sass/layout/_header.scss b/assets/sass/layout/_header.scss index 9f891acba..3a25edea8 100644 --- a/assets/sass/layout/_header.scss +++ b/assets/sass/layout/_header.scss @@ -28,7 +28,7 @@ $header-bg: $white; } &.site-header-fixed, - &.site-header-sticky--active { + &.site-header-sticky.scrolled { position: fixed; width: 100%; top: var(--ngtoolbar-height, 0); diff --git a/assets/sass/layout/_navigation.scss b/assets/sass/layout/_navigation.scss index 461ce0022..20a7f06ba 100644 --- a/assets/sass/layout/_navigation.scss +++ b/assets/sass/layout/_navigation.scss @@ -11,32 +11,78 @@ > li { position: relative; > a, - > span { + > span, + > .submenu-trigger { @extend %hover-underline; @include text-link; display: inline-block; padding: 0 1.3333333333em; } + .submenu-trigger { + position: relative; + padding-right: 1.75em; + } + > a + .submenu-trigger { + padding-left: .25em; + } + &:has(> a + .submenu-trigger) { + padding-right: 2em; + .submenu-trigger { + position: absolute; + right: 0; + top: 0; + width: 2em; + display: block; + } + } } } /* main submenu */ .menu_level_1 { list-style-type: none; - padding: 1rem 0; + padding: .5rem 0; margin: 0; - display: none; + visibility: hidden; + opacity: 0; + transform: translateY(-.25rem) scaleY(.85); + transform-origin: top; + transition: .1s; a { display: block; padding: .5em 1.5em; } } + .submenu-active { + > .menu_level_1 { + visibility: visible; + opacity: 1; + transform: translateY(0) scaleY(1); + } + } + .submenu-trigger { + &::before { + content: ''; + position: absolute; + right: .75em; + top: 50%; + transform: translateY(-50%); + width: .625em; + height: .625em; + background-color: currentColor; + mask: url(../assets/icons/angle-down.svg) no-repeat center; + mask-size: contain; + transition: .15s; + } + } /* large screen sizes */ @include media-breakpoint-up($collapse-nav) { .navbar-nav { + > li:has(.submenu-active), > li:hover, .active { > a, - > span { + > span, + > .submenu-trigger { color: $black; &::after { transform: scaleY(1); @@ -45,17 +91,18 @@ } > li { > a, - > span { + > span, + > .submenu-trigger { display: flex; height: $header-height; align-items: center; justify-content: center; color: $gray-54; } - &:hover { - .menu_level_1 { - display: block; - } + } + > li.submenu-active { + .submenu-trigger::before { + transform: translateY(-60%) rotate(180deg); } } } @@ -75,13 +122,14 @@ } } } - .submenu-trigger { - // NOTE: Removed fontawesome extend rule since the nav will we rewritten - display: none; - } /* small screen sizes */ @include media-breakpoint-down($collapse-nav) { - display: none; + display: flex; + flex-direction: column; + visibility: hidden; + opacity: 0; + transform: scale(.95); + transition: .1s; position: absolute; left: calc(var(--bs-gutter-x) * .5); right: calc(var(--bs-gutter-x) * .5); @@ -95,52 +143,53 @@ padding: 1rem 0; > li { > a, - > span { + > span, + > .submenu-trigger { padding: .5em 1em; color: $gray-87; &::after { display: none; } } - &[data-submenu='true'] { - > a { - margin: 0 1.875rem; - } + .submenu-trigger { + padding-right: 2em; } &.submenu-active { .menu_level_1 { - display: block; + max-height: var(--max-height); } .submenu-trigger { &::before { - transform: rotate(180deg); + transform: translateY(-60%) rotate(180deg); } } } + &:has(> a + .submenu-trigger) { + padding-right: 0; + .submenu-trigger { + position: relative; + height: 1.5em; + display: inline-block; + } + } } } .mainnav-active & { - display: block; + visibility: visible; + opacity: 1; + transform: scale(1); } .submenu-trigger { - display: inline-block; - position: absolute; right: 0; top: 0; - cursor: pointer; - width: 1.875rem; - height: 1.875rem; - line-height: 1.875rem; - @include text-0_875; - &::before { - display: block; - content: '\f078'; - } } .menu_level_1 { - padding: 0 0 1rem; + max-height: 0; + overflow: hidden; + padding: 0; + transform: translateY(-.25rem); + transition: .25s; a { - color: $gray-54; @include text-0_875; } } @@ -159,6 +208,17 @@ } } +.submenu-trigger { + text-align: inherit; + color: inherit; + background-color: transparent; + border: 0; + .no-triggers &::after, + .no-triggers a + & { + display: none; + } +} + .mainnav-toggle { @extend %hover-underline; display: none; diff --git a/templates/themes/app/forms/theme.html.twig b/templates/themes/app/forms/theme.html.twig index 514fce6c1..54565c8ab 100644 --- a/templates/themes/app/forms/theme.html.twig +++ b/templates/themes/app/forms/theme.html.twig @@ -155,7 +155,7 @@ {{- form_widget(form) -}} @@ -275,7 +275,7 @@ {% if required %} - {{ 'ngsite.collected_info.mandatory_field'|trans }} + {{ 'ngsite.collected_info.mandatory_field'|trans }} {% endif %} {%- endif -%} @@ -325,7 +325,7 @@ {% if required %} - {{ 'ngsite.collected_info.mandatory_field'|trans }} + {{ 'ngsite.collected_info.mandatory_field'|trans }} {% endif %} {%- endif -%} diff --git a/templates/themes/app/modules/knp_menu/menu.html.twig b/templates/themes/app/modules/knp_menu/menu.html.twig index 03d71df76..17e107789 100644 --- a/templates/themes/app/modules/knp_menu/menu.html.twig +++ b/templates/themes/app/modules/knp_menu/menu.html.twig @@ -43,12 +43,26 @@ {%- endif %} {# END NGSTACK-448 #} + {# Generate unique submenu ID for ARIA attributes #} + {%- set submenuId = null %} + {%- if item.hasChildren and options.depth is not same as(0) and item.displayChildren %} + {%- if item.extras.ibexa_location is defined and item.extras.ibexa_location is not empty %} + {%- set submenuId = 'submenu-' ~ item.extras.ibexa_location.id %} + {%- else %} + {%- set submenuId = 'submenu-' ~ item.name|lower|replace({' ': '-', '_': '-'}) %} + {%- endif %} + {%- endif %} + {# displaying the item #} {% import _self as knp_menu %} {%- if item.uri is not empty and (not matcher.isCurrent(item) or options.currentAsLink) %} {{ block('linkElement') }} + {%- elseif submenuId is not empty %} + {# Item has submenu - use button for accessibility #} + {%- else %} + {# Item has no link and no submenu - fallback to span #} {{ block('spanElement') }} {%- endif %} @@ -56,6 +70,9 @@ {%- set childrenClasses = item.childrenAttribute('class') is not empty ? [item.childrenAttribute('class')] : [] %} {%- set childrenClasses = childrenClasses|merge(['menu_level_' ~ item.level]) %} {%- set listAttributes = item.childrenAttributes|merge({'class': childrenClasses|join(' ') }) %} + {%- if submenuId is not empty %} + {%- set listAttributes = listAttributes|merge({'id': submenuId, 'aria-hidden': 'true'}) %} + {%- endif %} {{ block('list') }} diff --git a/templates/themes/app/pagelayout/footer.html.twig b/templates/themes/app/pagelayout/footer.html.twig index 02cd490c4..e9eaeb47d 100644 --- a/templates/themes/app/pagelayout/footer.html.twig +++ b/templates/themes/app/pagelayout/footer.html.twig @@ -4,7 +4,7 @@
{% include '@ibexadesign/content/parts/site_logo.html.twig' %} -