first commit

This commit is contained in:
2024-07-15 12:33:27 +02:00
commit ce50ae282b
22084 changed files with 2623791 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
/**
* @file
* Customization of checkbox.
*/
((Drupal) => {
/**
* Constructs a checkbox input element.
*
* @return {string}
* A string representing a DOM fragment.
*/
Drupal.theme.checkbox = () =>
'<input type="checkbox" class="form-checkbox form-boolean form-boolean--type-checkbox"/>';
})(Drupal);

View File

@@ -0,0 +1,221 @@
/**
* @file
* Provides UI/UX progressive enhancements on Olivero's theme settings by
* creating an HTMLColorInput element and synchronizing its input with a text
* input to provide an accessible and user-friendly interface. Additionally,
* provides a select element with pre-defined color values for easy color
* switching.
*/
((Drupal, settings, once) => {
const colorSchemeOptions = settings.olivero.colorSchemes;
/**
* Announces the text value of the field's label.
*
* @param {HTMLElement} changedInput
* The form element that was changed.
*/
function announceFieldChange(changedInput) {
const fieldTitle =
changedInput.parentElement.querySelector('label').innerText;
const fieldValue = changedInput.value;
const announcement = Drupal.t('@fieldName has changed to @fieldValue', {
'@fieldName': fieldTitle,
'@fieldValue': fieldValue,
});
Drupal.announce(announcement);
}
/**
* Formats hexcode to full 6-character string for HTMLColorInput.
*
* @param {string} hex The hex code input.
* @returns {string} The same hex code, formatted.
*/
function formatHex(hex) {
// Temporarily remove hash
if (hex.startsWith('#')) {
hex = hex.substring(1);
}
// Convert three-value to six-value syntax.
if (hex.length === 3) {
hex = hex
.split('')
.flatMap((character) => [character, character])
.join('');
}
hex = `#${hex}`;
return hex;
}
/**
* `input` event callback to keep text & color inputs in sync.
*
* @param {HTMLElement} changedInput input element changed by user
* @param {HTMLElement} inputToSync input element to synchronize
*/
function synchronizeInputs(changedInput, inputToSync) {
inputToSync.value = formatHex(changedInput.value);
changedInput.setAttribute(
'data-olivero-custom-color',
formatHex(changedInput.value),
);
inputToSync.setAttribute(
'data-olivero-custom-color',
formatHex(changedInput.value),
);
const colorSchemeSelect = document.querySelector(
'[data-drupal-selector="edit-color-scheme"]',
);
if (colorSchemeSelect.value !== '') {
colorSchemeSelect.value = '';
announceFieldChange(colorSchemeSelect);
}
}
/**
* Set individual colors when a pre-defined color scheme is selected.
*
* @param {Event.target} target input element for which the value has changed.
*/
function setColorScheme({ target }) {
if (!target.value) return;
const selectedColorScheme = colorSchemeOptions[target.value].colors;
if (selectedColorScheme) {
Object.entries(selectedColorScheme).forEach(([key, color]) => {
document
.querySelectorAll(`input[name="${key}"], input[name="${key}_visual"]`)
.forEach((input) => {
if (input.value !== color) {
input.value = color;
if (input.type === 'text') {
announceFieldChange(input);
}
}
});
});
} else {
document
.querySelectorAll(`input[data-olivero-custom-color]`)
.forEach((input) => {
input.value = input.getAttribute('data-olivero-custom-color');
});
}
}
/**
* Displays and initializes the color scheme selector.
*
* @param {HTMLSelectElement} selectElement div[data-drupal-selector="edit-color-scheme"]
*/
function initColorSchemeSelect(selectElement) {
selectElement.closest('[style*="display:none;"]').style.display = '';
selectElement.addEventListener('change', setColorScheme);
Object.entries(colorSchemeOptions).forEach((option) => {
const [key, values] = option;
const { label, colors } = values;
let allColorsMatch = true;
Object.entries(colors).forEach(([colorName, colorHex]) => {
const field = document.querySelector(
`input[type="text"][name="${colorName}"]`,
);
if (field.value !== colorHex) {
allColorsMatch = false;
}
});
if (allColorsMatch) {
selectElement.value = key;
}
});
}
/**
* Initializes Olivero theme-settings color picker.
* creates a color-type input and inserts it after the original text field.
* modifies aria values to make label apply to both inputs.
* adds event listeners to keep text & color inputs in sync.
*
* @param {HTMLElement} textInput The textfield input from the Drupal form API
*/
function initColorPicker(textInput) {
// Create input element.
const colorInput = document.createElement('input');
// Set new input's attributes.
colorInput.type = 'color';
colorInput.classList.add(
'form-color',
'form-element',
'form-element--type-color',
'form-element--api-color',
);
colorInput.value = formatHex(textInput.value);
colorInput.setAttribute('name', `${textInput.name}_visual`);
colorInput.setAttribute(
'data-olivero-custom-color',
textInput.getAttribute('data-olivero-custom-color'),
);
// Insert new input into DOM.
textInput.after(colorInput);
// Make field label apply to textInput and colorInput.
const fieldID = textInput.id;
const label = document.querySelector(`label[for="${fieldID}"]`);
label.removeAttribute('for');
label.setAttribute('id', `${fieldID}-label`);
textInput.setAttribute('aria-labelledby', `${fieldID}-label`);
colorInput.setAttribute('aria-labelledby', `${fieldID}-label`);
// Add `input` event listener to keep inputs synchronized.
textInput.addEventListener('input', () => {
synchronizeInputs(textInput, colorInput);
});
colorInput.addEventListener('input', () => {
synchronizeInputs(colorInput, textInput);
});
}
/**
* Olivero Color Picker behavior.
*
* @type {Drupal~behavior}
* @prop {Drupal~behaviorAttach} attach
* Initializes color picker fields.
*/
Drupal.behaviors.oliveroColorPicker = {
attach: () => {
const colorSchemeSelect = once(
'olivero-color-picker',
'[data-drupal-selector="edit-color-scheme"]',
);
colorSchemeSelect.forEach((selectElement) => {
initColorSchemeSelect(selectElement);
});
const colorTextInputs = once(
'olivero-color-picker',
'[data-drupal-selector="olivero-color-picker"] input[type="text"]',
);
colorTextInputs.forEach((textInput) => {
initColorPicker(textInput);
});
},
};
})(Drupal, drupalSettings, once);

View File

@@ -0,0 +1,63 @@
/**
* @file
* Customization of comments.
*/
((Drupal, once) => {
/**
* Initialize show/hide button for the comments.
*
* @param {Element} comments
* The comment wrapper element.
*/
function init(comments) {
comments
.querySelectorAll('[data-drupal-selector="comment"]')
.forEach((comment) => {
if (
comment.nextElementSibling != null &&
comment.nextElementSibling.matches('.indented')
) {
comment.classList.add('has-children');
}
});
comments.querySelectorAll('.indented').forEach((commentGroup) => {
const showHideWrapper = document.createElement('div');
showHideWrapper.setAttribute('class', 'show-hide-wrapper');
const toggleCommentsBtn = document.createElement('button');
toggleCommentsBtn.setAttribute('type', 'button');
toggleCommentsBtn.setAttribute('aria-expanded', 'true');
toggleCommentsBtn.setAttribute('class', 'show-hide-btn');
toggleCommentsBtn.innerText = Drupal.t('Replies');
commentGroup.parentNode.insertBefore(showHideWrapper, commentGroup);
showHideWrapper.appendChild(toggleCommentsBtn);
toggleCommentsBtn.addEventListener('click', (e) => {
commentGroup.classList.toggle('hidden');
e.currentTarget.setAttribute(
'aria-expanded',
commentGroup.classList.contains('hidden') ? 'false' : 'true',
);
});
});
}
/**
* Attaches the comment behavior to comments.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches the show/hide behavior for indented comments.
*/
Drupal.behaviors.comments = {
attach(context) {
once('comments', '[data-drupal-selector="comments"]', context).forEach(
init,
);
},
};
})(Drupal, once);

View File

@@ -0,0 +1,81 @@
/**
* @file
* Overriding core's message functions to add icon and a remove button to each message.
*/
((Drupal) => {
/**
* Overrides message theme function.
*
* @param {object} message
* The message object.
* @param {string} message.text
* The message text.
* @param {object} options
* The message context.
* @param {string} options.type
* The message type.
* @param {string} options.id
* ID of the message, for reference.
*
* @return {HTMLElement}
* A DOM Node.
*/
Drupal.theme.message = ({ text }, { type, id }) => {
const messagesTypes = Drupal.Message.getMessageTypeLabels();
const messageWrapper = document.createElement('div');
messageWrapper.setAttribute(
'class',
`messages-list__item messages messages--${type}`,
);
messageWrapper.setAttribute('data-drupal-selector', 'messages');
messageWrapper.setAttribute(
'role',
type === 'error' || type === 'warning' ? 'alert' : 'status',
);
messageWrapper.setAttribute('aria-labelledby', `${id}-title`);
messageWrapper.setAttribute('data-drupal-message-id', id);
messageWrapper.setAttribute('data-drupal-message-type', type);
let svg = '';
if (['error', 'warning', 'status', 'info'].indexOf(type) > -1) {
svg =
'<div class="messages__icon"><svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">';
}
if (type === 'error') {
svg +=
'<path d="M0.117801 16.7381C0.202622 17.9153 0.292289 18.562 0.481317 19.3904C0.922384 21.3161 1.6785 23.0626 2.76178 24.6589C4.58178 27.3355 7.18213 29.3823 10.1993 30.5062C12.458 31.3467 14.942 31.6495 17.3461 31.3782C22.5831 30.7872 27.1246 27.6164 29.4875 22.9027C30.3769 21.132 30.8616 19.3929 31.0797 17.1983C31.1209 16.7768 31.1209 15.1733 31.0797 14.7518C30.8786 12.7195 30.4714 11.1693 29.7032 9.49549C28.3848 6.62269 26.1722 4.18589 23.4289 2.58235C19.4399 0.249712 14.5373 -0.171762 10.1993 1.44389C5.82985 3.07165 2.38372 6.62753 0.915114 11.0215C0.512822 12.223 0.289865 13.2863 0.161423 14.604C0.127495 14.9674 0.0959901 16.4425 0.117801 16.7381ZM4.02924 14.9577C4.2837 12.2108 5.46391 9.69412 7.40024 7.76115C9.15966 6.00743 11.3529 4.89319 13.8224 4.49352C14.4234 4.39905 14.8717 4.36514 15.6012 4.36271C16.6941 4.36271 17.4793 4.45718 18.5093 4.71636C19.2969 4.91257 20.0143 5.17902 20.7873 5.55931C21.2526 5.78943 21.9409 6.18183 21.9554 6.22786C21.9651 6.25692 5.90498 22.3093 5.86621 22.3093C5.82501 22.3093 5.46391 21.6916 5.21915 21.2071C4.51877 19.8071 4.10921 18.2956 4.005 16.7138C3.98077 16.336 3.99288 15.3453 4.02924 14.9577ZM25.3725 9.6384C25.4428 9.7038 25.816 10.3602 26.0341 10.8035C26.6618 12.0776 27.0301 13.359 27.1876 14.8366C27.2385 15.2968 27.2458 16.5225 27.2022 16.9561C27.0859 18.0776 26.8847 18.9786 26.526 19.9669C26.2377 20.7663 25.7748 21.6843 25.2998 22.394C23.8748 24.5232 21.7882 26.1364 19.3987 26.9576C18.1046 27.4009 16.985 27.585 15.5891 27.585C14.8232 27.585 14.4646 27.5607 13.786 27.4541C12.2568 27.2192 10.6574 26.6209 9.40685 25.8191L9.23237 25.7077L17.2879 17.6609C23.3562 11.598 25.3507 9.61903 25.3725 9.6384Z"/>';
} else if (type === 'warning') {
svg +=
'<path d="M16 0C7.16667 0 0 7.16667 0 16C0 24.8333 7.16667 32 16 32C24.8333 32 32 24.8333 32 16C32 7.16667 24.8333 0 16 0ZM18.6667 25.9792C18.6667 26.3542 18.375 26.6667 18.0208 26.6667H14.0208C13.6458 26.6667 13.3333 26.3542 13.3333 25.9792V22.0208C13.3333 21.6458 13.6458 21.3333 14.0208 21.3333H18.0208C18.375 21.3333 18.6667 21.6458 18.6667 22.0208V25.9792ZM18.625 18.8125C18.6042 19.1042 18.2917 19.3333 17.9167 19.3333H14.0625C13.6667 19.3333 13.3542 19.1042 13.3542 18.8125L13 5.875C13 5.72917 13.0625 5.58333 13.2083 5.5C13.3333 5.39583 13.5208 5.33333 13.7083 5.33333H18.2917C18.4792 5.33333 18.6667 5.39583 18.7917 5.5C18.9375 5.58333 19 5.72917 19 5.875L18.625 18.8125Z"/>';
} else if (type === 'status') {
svg +=
'<path d="M26.75 12.625C26.75 12.9792 26.625 13.3125 26.375 13.5625L15.0625 24.875C14.8125 25.125 14.4583 25.2708 14.1042 25.2708C13.7708 25.2708 13.4167 25.125 13.1667 24.875L5.625 17.3333C5.375 17.0833 5.25 16.75 5.25 16.3958C5.25 16.0417 5.375 15.6875 5.625 15.4375L7.52083 13.5625C7.77083 13.3125 8.10417 13.1667 8.45833 13.1667C8.8125 13.1667 9.14583 13.3125 9.39583 13.5625L14.1042 18.2708L22.6042 9.79167C22.8542 9.54167 23.1875 9.39583 23.5417 9.39583C23.8958 9.39583 24.2292 9.54167 24.4792 9.79167L26.375 11.6667C26.625 11.9167 26.75 12.2708 26.75 12.625ZM32 16C32 7.16667 24.8333 0 16 0C7.16667 0 0 7.16667 0 16C0 24.8333 7.16667 32 16 32C24.8333 32 32 24.8333 32 16Z"/>';
} else if (type === 'info') {
svg +=
'<path d="M32,16c0,8.8-7.2,16-16,16S0,24.8,0,16C0,7.2,7.2,0,16,0S32,7.2,32,16z M16.4,5.3c-3.5,0-5.8,1.5-7.5,4.1c-0.2,0.3-0.2,0.8,0.2,1l2.2,1.7c0.3,0.3,0.8,0.2,1.1-0.1c1.2-1.5,1.9-2.3,3.7-2.3c1.3,0,2.9,0.8,2.9,2.1c0,1-0.8,1.5-2.1,2.2c-1.5,0.9-3.5,1.9-3.5,4.6v0.3c0,0.4,0.3,0.8,0.8,0.8h3.6c0.4,0,0.8-0.3,0.8-0.8v-0.1c0-1.8,5.4-1.9,5.4-6.9C23.9,8.1,20.1,5.3,16.4,5.3z M16,21.3c-1.6,0-3,1.3-3,3c0,1.6,1.3,3,3,3s3-1.3,3-3C19,22.6,17.6,21.3,16,21.3z"/>';
}
if (['error', 'warning', 'status', 'info'].indexOf(type) > -1) {
svg += '</svg></div>';
}
messageWrapper.innerHTML = `
<div class="messages__container" data-drupal-selector="messages-container">
<div class="messages__header${!svg ? ' no-icon' : ''}">
<h2 class="visually-hidden">${messagesTypes[type]}</h2>
${svg}
</div>
<div class="messages__content">
${text}
</div>
</div>
`;
Drupal.olivero.closeMessage(messageWrapper);
return messageWrapper;
};
})(Drupal);

View File

@@ -0,0 +1,56 @@
/**
* @file
* Customization of messages.
*/
((Drupal, once) => {
/**
* Adds a close button to the message.
*
* @param {object} message
* The message object.
*/
const closeMessage = (message) => {
const messageContainer = message.querySelector(
'[data-drupal-selector="messages-container"]',
);
if (!messageContainer.querySelector('.messages__button')) {
const closeBtnWrapper = document.createElement('div');
closeBtnWrapper.setAttribute('class', 'messages__button');
const closeBtn = document.createElement('button');
closeBtn.setAttribute('type', 'button');
closeBtn.setAttribute('class', 'messages__close');
const closeBtnText = document.createElement('span');
closeBtnText.setAttribute('class', 'visually-hidden');
closeBtnText.innerText = Drupal.t('Close message');
messageContainer.appendChild(closeBtnWrapper);
closeBtnWrapper.appendChild(closeBtn);
closeBtn.appendChild(closeBtnText);
closeBtn.addEventListener('click', () => {
message.classList.add('hidden');
});
}
};
/**
* Get messages from context.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches the close button behavior for messages.
*/
Drupal.behaviors.messages = {
attach(context) {
once('messages', '[data-drupal-selector="messages"]', context).forEach(
closeMessage,
);
},
};
Drupal.olivero.closeMessage = closeMessage;
})(Drupal, once);

View File

@@ -0,0 +1,86 @@
/**
* @file
* This script watches the desktop version of the primary navigation. If it
* wraps to two lines, it will automatically transition to a mobile navigation
* and remember where it wrapped so it can transition back.
*/
((Drupal, once) => {
/**
* Handles the transition from mobile navigation to desktop navigation.
*
* @param {Element} navWrapper - The primary navigation's top-level <ul> element.
* @param {Element} navItem - The first item within the primary navigation.
*/
function transitionToDesktopNavigation(navWrapper, navItem) {
document.body.classList.remove('is-always-mobile-nav');
// Double check to see if the navigation is wrapping, and if so, re-enable
// mobile navigation. This solves an edge cases where if the amount of
// navigation items always causes the primary navigation to wrap, and the
// page is loaded at a narrower viewport and then widened, the mobile nav
// may not be enabled.
if (navWrapper.clientHeight > navItem.clientHeight) {
document.body.classList.add('is-always-mobile-nav');
}
}
/**
* Callback from Resize Observer. This checks if the primary navigation is
* wrapping, and if so, transitions to the mobile navigation.
*
* @param {ResizeObserverEntry} entries - Object passed from ResizeObserver.
*/
function checkIfDesktopNavigationWraps(entries) {
const navItem = document.querySelector('.primary-nav__menu-item');
if (
Drupal.olivero.isDesktopNav() &&
entries[0].contentRect.height > navItem.clientHeight
) {
const navMediaQuery = window.matchMedia(
`(max-width: ${window.innerWidth + 15}px)`, // 5px adds a small buffer before switching back.
);
document.body.classList.add('is-always-mobile-nav');
// In the event that the viewport was resized, we remember the viewport
// width with a one-time event listener ,so we can attempt to transition
// from mobile navigation to desktop navigation.
navMediaQuery.addEventListener(
'change',
() => {
transitionToDesktopNavigation(entries[0].target, navItem);
},
{ once: true },
);
}
}
/**
* Set up Resize Observer to listen for changes to the size of the primary
* navigation.
*
* @param {Element} primaryNav - The primary navigation's top-level <ul> element.
*/
function init(primaryNav) {
const resizeObserver = new ResizeObserver(checkIfDesktopNavigationWraps);
resizeObserver.observe(primaryNav);
}
/**
* Initialize the automatic navigation transition.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attach context and settings for navigation.
*/
Drupal.behaviors.automaticMobileNav = {
attach(context) {
once(
'olivero-automatic-mobile-nav',
'[data-drupal-selector="primary-nav-menu--level-1"]',
context,
).forEach(init);
},
};
})(Drupal, once);

View File

@@ -0,0 +1,215 @@
/**
* @file
* Controls the visibility of desktop navigation.
*
* Shows and hides the desktop navigation based on scroll position and controls
* the functionality of the button that shows/hides the navigation.
*/
/* eslint-disable no-inner-declarations */
((Drupal) => {
/**
* Olivero helper functions.
*
* @namespace
*/
Drupal.olivero = {};
/**
* Checks if the mobile navigation button is visible.
*
* @return {boolean}
* True if navButtons is hidden, false if not.
*/
function isDesktopNav() {
const navButtons = document.querySelector(
'[data-drupal-selector="mobile-buttons"]',
);
return navButtons
? window.getComputedStyle(navButtons).getPropertyValue('display') ===
'none'
: false;
}
Drupal.olivero.isDesktopNav = isDesktopNav;
const stickyHeaderToggleButton = document.querySelector(
'[data-drupal-selector="sticky-header-toggle"]',
);
const siteHeaderFixable = document.querySelector(
'[data-drupal-selector="site-header-fixable"]',
);
/**
* Checks if the sticky header is enabled.
*
* @return {boolean}
* True if sticky header is enabled, false if not.
*/
function stickyHeaderIsEnabled() {
return stickyHeaderToggleButton.getAttribute('aria-checked') === 'true';
}
/**
* 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()) {
siteHeaderFixable.classList.toggle('is-expanded', pinnedState);
stickyHeaderToggleButton.setAttribute('aria-checked', pinnedState);
setStickyHeaderStorage(pinnedState);
}
}
/**
* 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 false;
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 false;
}
return item.value;
}
// Only enable scroll interactivity if the browser supports Intersection
// Observer.
// @see https://github.com/w3c/IntersectionObserver/blob/master/polyfill/intersection-observer.js#L19-L21
if (
'IntersectionObserver' in window &&
'IntersectionObserverEntry' in window &&
'intersectionRatio' in window.IntersectionObserverEntry.prototype
) {
const fixableElements = document.querySelectorAll(
'[data-drupal-selector="site-header-fixable"], [data-drupal-selector="social-bar-inner"]',
);
function toggleDesktopNavVisibility(entries) {
if (!isDesktopNav()) return;
entries.forEach((entry) => {
// Firefox doesn't seem to support entry.isIntersecting properly,
// so we check the intersectionRatio.
fixableElements.forEach((el) =>
el.classList.toggle('is-fixed', entry.intersectionRatio < 1),
);
});
}
/**
* Gets the root margin by checking for various toolbar classes.
*
* @return {string}
* Root margin for the Intersection Observer options object.
*/
function getRootMargin() {
let rootMarginTop = 72;
const { body } = document;
if (body.classList.contains('toolbar-fixed')) {
rootMarginTop -= 39;
}
if (
body.classList.contains('toolbar-horizontal') &&
body.classList.contains('toolbar-tray-open')
) {
rootMarginTop -= 40;
}
return `${rootMarginTop}px 0px 0px 0px`;
}
/**
* Monitor the navigation position.
*/
function monitorNavPosition() {
const primaryNav = document.querySelector(
'[data-drupal-selector="site-header"]',
);
const options = {
rootMargin: getRootMargin(),
threshold: [0.999, 1],
};
const observer = new IntersectionObserver(
toggleDesktopNavVisibility,
options,
);
if (primaryNav) {
observer.observe(primaryNav);
}
}
if (stickyHeaderToggleButton) {
stickyHeaderToggleButton.addEventListener('click', () => {
toggleStickyHeaderState(!stickyHeaderIsEnabled());
});
}
// 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.
const siteHeaderInner = document.querySelector(
'[data-drupal-selector="site-header-inner"]',
);
if (siteHeaderInner) {
siteHeaderInner.addEventListener('focusin', () => {
if (isDesktopNav() && !stickyHeaderIsEnabled()) {
const header = document.querySelector(
'[data-drupal-selector="site-header"]',
);
const headerNav = header.querySelector(
'[data-drupal-selector="header-nav"]',
);
const headerMargin = header.clientHeight - headerNav.clientHeight;
if (window.scrollY > headerMargin) {
window.scrollTo(0, headerMargin);
}
}
});
}
monitorNavPosition();
setStickyHeaderStorage(getStickyHeaderStorage());
toggleStickyHeaderState(getStickyHeaderStorage());
}
})(Drupal);

View File

@@ -0,0 +1,160 @@
/**
* @file
* Customization of navigation.
*/
((Drupal, once, tabbable) => {
/**
* Checks if navWrapper contains "is-active" class.
*
* @param {Element} navWrapper
* Header navigation.
*
* @return {boolean}
* True if navWrapper contains "is-active" class, false if not.
*/
function isNavOpen(navWrapper) {
return navWrapper.classList.contains('is-active');
}
/**
* Opens or closes the header navigation.
*
* @param {object} props
* Navigation props.
* @param {boolean} state
* State which to transition the header navigation menu into.
*/
function toggleNav(props, state) {
const value = !!state;
props.navButton.setAttribute('aria-expanded', value);
props.body.classList.toggle('is-overlay-active', value);
props.body.classList.toggle('is-fixed', value);
props.navWrapper.classList.toggle('is-active', value);
}
/**
* Initialize the header navigation.
*
* @param {object} props
* Navigation props.
*/
function init(props) {
props.navButton.setAttribute('aria-controls', props.navWrapperId);
props.navButton.setAttribute('aria-expanded', 'false');
props.navButton.addEventListener('click', () => {
toggleNav(props, !isNavOpen(props.navWrapper));
});
// Close any open sub-navigation first, then close the header navigation.
document.addEventListener('keyup', (e) => {
if (e.key === 'Escape') {
if (props.olivero.areAnySubNavsOpen()) {
props.olivero.closeAllSubNav();
} else {
toggleNav(props, false);
}
}
});
props.overlay.addEventListener('click', () => {
toggleNav(props, false);
});
props.overlay.addEventListener('touchstart', () => {
toggleNav(props, false);
});
// Focus trap. This is added to the header element because the navButton
// element is not a child element of the navWrapper element, and the keydown
// event would not fire if focus is on the navButton element.
props.header.addEventListener('keydown', (e) => {
if (e.key === 'Tab' && isNavOpen(props.navWrapper)) {
const tabbableNavElements = tabbable.tabbable(props.navWrapper);
tabbableNavElements.unshift(props.navButton);
const firstTabbableEl = tabbableNavElements[0];
const lastTabbableEl =
tabbableNavElements[tabbableNavElements.length - 1];
if (e.shiftKey) {
if (
document.activeElement === firstTabbableEl &&
!props.olivero.isDesktopNav()
) {
lastTabbableEl.focus();
e.preventDefault();
}
} else if (
document.activeElement === lastTabbableEl &&
!props.olivero.isDesktopNav()
) {
firstTabbableEl.focus();
e.preventDefault();
}
}
});
// Remove overlays when browser is resized and desktop nav appears.
window.addEventListener('resize', () => {
if (props.olivero.isDesktopNav()) {
toggleNav(props, false);
props.body.classList.remove('is-overlay-active');
props.body.classList.remove('is-fixed');
}
// Ensure that all sub-navigation menus close when the browser is resized.
Drupal.olivero.closeAllSubNav();
});
// If hyperlink links to an anchor in the current page, close the
// mobile menu after the click.
props.navWrapper.addEventListener('click', (e) => {
if (
e.target.matches(
`[href*="${window.location.pathname}#"], [href*="${window.location.pathname}#"] *, [href^="#"], [href^="#"] *`,
)
) {
toggleNav(props, false);
}
});
}
/**
* Initialize the navigation.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attach context and settings for navigation.
*/
Drupal.behaviors.oliveroNavigation = {
attach(context) {
const headerId = 'header';
const header = once('navigation', `#${headerId}`, context).shift();
const navWrapperId = 'header-nav';
if (header) {
const navWrapper = header.querySelector(`#${navWrapperId}`);
const { olivero } = Drupal;
const navButton = context.querySelector(
'[data-drupal-selector="mobile-nav-button"]',
);
const body = document.body;
const overlay = context.querySelector(
'[data-drupal-selector="header-nav-overlay"]',
);
init({
olivero,
header,
navWrapperId,
navWrapper,
navButton,
body,
overlay,
});
}
},
};
})(Drupal, once, tabbable);

153
core/themes/olivero/js/search.js Executable file
View File

@@ -0,0 +1,153 @@
/**
* @file
* Wide viewport search bar interactions.
*/
((Drupal) => {
const searchWideButtonSelector =
'[data-drupal-selector="block-search-wide-button"]';
const searchWideButton = document.querySelector(searchWideButtonSelector);
const searchWideWrapperSelector =
'[data-drupal-selector="block-search-wide-wrapper"]';
const searchWideWrapper = document.querySelector(searchWideWrapperSelector);
/**
* Determine if search is visible.
*
* @return {boolean}
* True if the search wrapper contains "is-active" class, false if not.
*/
function searchIsVisible() {
return searchWideWrapper.classList.contains('is-active');
}
Drupal.olivero.searchIsVisible = searchIsVisible;
/**
* Closes search bar when a click event does not happen at an (x,y) coordinate
* that does not overlap with either the search wrapper or button.
*
* @see https://bugs.webkit.org/show_bug.cgi?id=229895
*
* @param {Event} e click event
*/
function watchForClickOut(e) {
const clickInSearchArea = e.target.matches(`
${searchWideWrapperSelector},
${searchWideWrapperSelector} *,
${searchWideButtonSelector},
${searchWideButtonSelector} *
`);
if (!clickInSearchArea && searchIsVisible()) {
// eslint-disable-next-line no-use-before-define
toggleSearchVisibility(false);
}
}
/**
* Closes search bar when focus moves to another target.
* Avoids closing search bar if event does not have related target - required for Safari.
*
* @see https://bugs.webkit.org/show_bug.cgi?id=229895
*
* @param {Event} e focusout event
*/
function watchForFocusOut(e) {
if (e.relatedTarget) {
const inSearchBar = e.relatedTarget.matches(
`${searchWideWrapperSelector}, ${searchWideWrapperSelector} *`,
);
const inSearchButton = e.relatedTarget.matches(
`${searchWideButtonSelector}, ${searchWideButtonSelector} *`,
);
if (!inSearchBar && !inSearchButton) {
// eslint-disable-next-line no-use-before-define
toggleSearchVisibility(false);
}
}
}
/**
* Closes search bar on escape keyup, if open.
*
* @param {Event} e keyup event
*/
function watchForEscapeOut(e) {
if (e.key === 'Escape') {
// eslint-disable-next-line no-use-before-define
toggleSearchVisibility(false);
}
}
/**
* Set focus for the search input element.
*/
function handleFocus() {
if (searchIsVisible()) {
searchWideWrapper.querySelector('input[type="search"]').focus();
} else if (searchWideWrapper.contains(document.activeElement)) {
// Return focus to button only if focus was inside of the search wrapper.
searchWideButton.focus();
}
}
/**
* Toggle search functionality visibility.
*
* @param {boolean} visibility
* True if we want to show the form, false if we want to hide it.
*/
function toggleSearchVisibility(visibility) {
searchWideButton.setAttribute('aria-expanded', visibility === true);
searchWideWrapper.classList.toggle('is-active', visibility === true);
searchWideWrapper.addEventListener('transitionend', handleFocus, {
once: true,
});
if (visibility === true) {
Drupal.olivero.closeAllSubNav();
document.addEventListener('click', watchForClickOut, { capture: true });
document.addEventListener('focusout', watchForFocusOut, {
capture: true,
});
document.addEventListener('keyup', watchForEscapeOut, { capture: true });
} else {
document.removeEventListener('click', watchForClickOut, {
capture: true,
});
document.removeEventListener('focusout', watchForFocusOut, {
capture: true,
});
document.removeEventListener('keyup', watchForEscapeOut, {
capture: true,
});
}
}
Drupal.olivero.toggleSearchVisibility = toggleSearchVisibility;
/**
* Initializes the search wide button.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Adds aria-expanded attribute to the search wide button.
*/
Drupal.behaviors.searchWide = {
attach(context) {
const searchWideButtonEl = once(
'search-wide',
searchWideButtonSelector,
context,
).shift();
if (searchWideButtonEl) {
searchWideButtonEl.setAttribute('aria-expanded', searchIsVisible());
searchWideButtonEl.addEventListener('click', () => {
toggleSearchVisibility(!searchIsVisible());
});
}
},
};
})(Drupal);

View File

@@ -0,0 +1,197 @@
/**
* @file
* Provides functionality for second level submenu navigation.
*/
((Drupal) => {
const { isDesktopNav } = Drupal.olivero;
const secondLevelNavMenus = document.querySelectorAll(
'[data-drupal-selector="primary-nav-menu-item-has-children"]',
);
/**
* Shows and hides the specified menu item's second level submenu.
*
* @param {Element} topLevelMenuItem
* The <li> element that is the container for the menu and submenus.
* @param {boolean} [toState]
* Optional state where we want the submenu to end up.
*/
function toggleSubNav(topLevelMenuItem, toState) {
const buttonSelector =
'[data-drupal-selector="primary-nav-submenu-toggle-button"]';
const button = topLevelMenuItem.querySelector(buttonSelector);
const state =
toState !== undefined
? toState
: button.getAttribute('aria-expanded') !== 'true';
if (state) {
// If desktop nav, ensure all menus close before expanding new one.
if (isDesktopNav()) {
secondLevelNavMenus.forEach((el) => {
el.querySelector(buttonSelector).setAttribute(
'aria-expanded',
'false',
);
el.querySelector(
'[data-drupal-selector="primary-nav-menu--level-2"]',
).classList.remove('is-active-menu-parent');
el.querySelector(
'[data-drupal-selector="primary-nav-menu-🥕"]',
).classList.remove('is-active-menu-parent');
});
}
} else {
topLevelMenuItem.classList.remove('is-touch-event');
}
button.setAttribute('aria-expanded', state);
topLevelMenuItem
.querySelector('[data-drupal-selector="primary-nav-menu--level-2"]')
.classList.toggle('is-active-menu-parent', state);
topLevelMenuItem
.querySelector('[data-drupal-selector="primary-nav-menu-🥕"]')
.classList.toggle('is-active-menu-parent', state);
}
Drupal.olivero.toggleSubNav = toggleSubNav;
/**
* Sets a timeout and closes current desktop navigation submenu if it
* does not contain the focused element.
*
* @param {Event} e
* The event object.
*/
function handleBlur(e) {
if (!Drupal.olivero.isDesktopNav()) return;
setTimeout(() => {
const menuParentItem = e.target.closest(
'[data-drupal-selector="primary-nav-menu-item-has-children"]',
);
if (!menuParentItem.contains(document.activeElement)) {
toggleSubNav(menuParentItem, false);
}
}, 200);
}
// Add event listeners onto each sub navigation parent and button.
secondLevelNavMenus.forEach((el) => {
const button = el.querySelector(
'[data-drupal-selector="primary-nav-submenu-toggle-button"]',
);
button.removeAttribute('aria-hidden');
button.removeAttribute('tabindex');
// If touch event, prevent mouseover event from triggering the submenu.
el.addEventListener(
'touchstart',
() => {
el.classList.add('is-touch-event');
},
{ passive: true },
);
el.addEventListener('mouseover', () => {
if (isDesktopNav() && !el.classList.contains('is-touch-event')) {
el.classList.add('is-active-mouseover-event');
toggleSubNav(el, true);
// Timeout is added to ensure that users of assistive devices (such as
// mouse grid tools) do not simultaneously trigger both the mouseover
// and click events. When these events are triggered together, the
// submenu to appear to not open.
setTimeout(() => {
el.classList.remove('is-active-mouseover-event');
}, 500);
}
});
button.addEventListener('click', () => {
if (!el.classList.contains('is-active-mouseover-event')) {
toggleSubNav(el);
}
});
el.addEventListener('mouseout', () => {
if (
isDesktopNav() &&
!document.activeElement.matches(
'[aria-expanded="true"], .is-active-menu-parent *',
)
) {
toggleSubNav(el, false);
}
});
el.addEventListener('blur', handleBlur, true);
});
/**
* Close all second level sub navigation menus.
*/
function closeAllSubNav() {
secondLevelNavMenus.forEach((el) => {
// Return focus to the toggle button if the submenu contains focus.
if (el.contains(document.activeElement)) {
el.querySelector(
'[data-drupal-selector="primary-nav-submenu-toggle-button"]',
).focus();
}
toggleSubNav(el, false);
});
}
Drupal.olivero.closeAllSubNav = closeAllSubNav;
/**
* Checks if any sub navigation items are currently active.
*
* @return {boolean}
* If sub navigation is currently open.
*/
function areAnySubNavsOpen() {
let subNavsAreOpen = false;
secondLevelNavMenus.forEach((el) => {
const button = el.querySelector(
'[data-drupal-selector="primary-nav-submenu-toggle-button"]',
);
const state = button.getAttribute('aria-expanded') === 'true';
if (state) {
subNavsAreOpen = true;
}
});
return subNavsAreOpen;
}
Drupal.olivero.areAnySubNavsOpen = areAnySubNavsOpen;
// Ensure that desktop submenus close when escape key is pressed.
document.addEventListener('keyup', (e) => {
if (e.key === 'Escape') {
if (isDesktopNav()) closeAllSubNav();
}
});
// If user taps outside of menu, close all menus.
document.addEventListener(
'touchstart',
(e) => {
if (
areAnySubNavsOpen() &&
!e.target.matches(
'[data-drupal-selector="header-nav"], [data-drupal-selector="header-nav"] *',
)
) {
closeAllSubNav();
}
},
{ passive: true },
);
})(Drupal);

69
core/themes/olivero/js/tabs.js Executable file
View File

@@ -0,0 +1,69 @@
/**
* @file
* Provides interactivity for showing and hiding the primary tabs at mobile widths.
*/
((Drupal, once) => {
/**
* Initialize the primary tabs.
*
* @param {HTMLElement} el
* The DOM element containing the primary tabs.
*/
function init(el) {
const tabs = el.querySelector('.tabs');
const expandedClass = 'is-expanded';
const activeTab = tabs.querySelector('.is-active');
/**
* Determines if primary tabs are expanded for mobile layouts.
*
* @return {boolean}
* Whether the tabs trigger element is expanded.
*/
function isTabsMobileLayout() {
return tabs.querySelector('.tabs__trigger').clientHeight > 0;
}
/**
* Controls primary tab visibility on click events.
*
* @param {Event} e
* The event object.
*/
function handleTriggerClick(e) {
e.currentTarget.setAttribute(
'aria-expanded',
!tabs.classList.contains(expandedClass),
);
tabs.classList.toggle(expandedClass);
}
if (isTabsMobileLayout() && !activeTab.matches('.tabs__tab:first-child')) {
const newActiveTab = activeTab.cloneNode(true);
const firstTab = tabs.querySelector('.tabs__tab:first-child');
tabs.insertBefore(newActiveTab, firstTab);
tabs.removeChild(activeTab);
}
tabs
.querySelector('.tabs__trigger')
.addEventListener('click', handleTriggerClick);
}
/**
* Initialize the primary tabs.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Display primary tabs according to the screen width.
*/
Drupal.behaviors.primaryTabs = {
attach(context) {
once('olivero-tabs', '[data-drupal-nav-primary-tabs]', context).forEach(
init,
);
},
};
})(Drupal, once);