Initial Drupal 11 with DDEV setup

This commit is contained in:
gluebox
2025-10-08 11:39:17 -04:00
commit 89ef74b305
25344 changed files with 2599172 additions and 0 deletions

View File

@ -0,0 +1,181 @@
/**
*
* Common JS managing behavior of admin-toolbar.
*
* Init Toolbar triggers.
*
* One trigger is button in Toolbar.
* Another button in control panel on mobile.
* Third is mobile shadow.
* Fourth is close sidebar button on mobile.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
*/
(
(Drupal, once) => {
/**
* Constant representing the event name for toggling the admin toolbar state.
* @type {string}
*/
const HTML_TRIGGER_EVENT = 'toggle-admin-toolbar';
/**
* Constant representing the event name for toggling the admin toolbar content.
* @type {string}
*/
const SIDEBAR_CONTENT_EVENT = 'toggle-admin-toolbar-content';
if (
once('admin-toolbar-document-triggers-listener', document.documentElement)
.length
) {
const doc = document.documentElement;
// This is special attribute which added to apply css
// with animations and avoid layout shift.
setTimeout(() => {
doc.setAttribute('data-admin-toolbar-transitions', true);
}, 100);
doc.addEventListener(HTML_TRIGGER_EVENT, (e) => {
// Prevents multiple triggering while transitioning.
const newState = e.detail.state;
const isUserInput = e.detail.manual;
document.documentElement.setAttribute(
'data-admin-toolbar',
newState ? 'expanded' : 'collapsed',
);
// Set [data-admin-toolbar-body-scroll='locked']
// See css/components/body-scroll-lock.pcss.css.
document.documentElement.setAttribute(
'data-admin-toolbar-body-scroll',
newState ? 'locked' : 'unlocked',
);
doc.querySelector('.admin-toolbar')?.dispatchEvent(
new CustomEvent(SIDEBAR_CONTENT_EVENT, {
detail: {
state: newState,
},
}),
);
if (isUserInput) {
document.documentElement.setAttribute(
'data-admin-toolbar-animating',
true,
);
}
setTimeout(() => {
document.documentElement.removeAttribute(
'data-admin-toolbar-animating',
);
}, 200);
Drupal.displace(true);
});
}
/**
* Initialize Drupal.displace()
*
* We add the displace attribute to a separate full width element because we
* don't want this element to have transitions. Note that this element and the
* navbar share the same exact width.
*
* @param {HTMLElement} el - The admin toolbar wrapper.
*/
const initDisplace = (el) => {
const displaceElement = el.querySelector(
'.admin-toolbar__displace-placeholder',
);
const edge = document.documentElement.dir === 'rtl' ? 'right' : 'left';
displaceElement?.setAttribute(`data-offset-${edge}`, '');
Drupal.displace(true);
};
// Any triggers on page. Inside or outside sidebar.
// For now button in sidebar + mobile header and background.
Drupal.behaviors.navigationProcessToolbarTriggers = {
attach: (context) => {
once('navigation-displace', '.admin-toolbar', context).forEach(
initDisplace,
);
const triggers = once(
'admin-toolbar-trigger',
'[aria-controls="admin-toolbar"]',
context,
);
/**
* Updates the state of all trigger elements based on the provided state.
*
* @param {boolean} toState The new state of the sidebar.
*/
const toggleTriggers = (toState) => {
triggers.forEach((trigger) => {
trigger.setAttribute('aria-expanded', toState);
const text =
trigger.querySelector('[data-toolbar-text]') ||
trigger.querySelector('[data-toolbar-action]');
if (text) {
text.textContent = toState
? Drupal.t('Collapse sidebar')
: Drupal.t('Expand sidebar');
}
});
localStorage.setItem('Drupal.navigation.sidebarExpanded', toState);
};
if (triggers.length) {
let firstState =
localStorage.getItem('Drupal.navigation.sidebarExpanded') !==
'false';
// We need to display closed sidebar on init on mobile.
if (window.matchMedia('(max-width: 1023px)').matches) {
firstState = false;
}
// Set values on load.
toggleTriggers(firstState);
document.documentElement.dispatchEvent(
new CustomEvent(HTML_TRIGGER_EVENT, {
bubbles: true,
detail: {
state: firstState,
manual: false,
},
}),
);
triggers.forEach((trigger) => {
trigger.addEventListener('click', (e) => {
const state =
e.currentTarget.getAttribute('aria-expanded') === 'false';
trigger.dispatchEvent(
new CustomEvent(HTML_TRIGGER_EVENT, {
bubbles: true,
detail: {
state,
manual: true,
},
}),
);
toggleTriggers(state);
});
});
}
},
};
}
)(Drupal, once);

View File

@ -0,0 +1,265 @@
/**
* @file
* Keyboard navigation component.
*/
((Drupal, once, { focusable }) => {
/**
* Attaches the keyboard navigation functionality.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches the behavior to the `.admin-toolbar` element.
*/
Drupal.behaviors.keyboardNavigation = {
attach: (context) => {
once('keyboard-processed', '.admin-toolbar', context).forEach(
(sidebar) => {
const IS_RTL = document.documentElement.dir === 'rtl';
const isInteractive = (element) =>
element.getAttribute('aria-expanded');
const getFocusableGroup = (element) =>
element.closest('[class*="toolbar-menu--level-"]') ||
element.closest('[data-toolbar-popover-wrapper]') ||
element.closest('.admin-toolbar');
const findFirstElementByChar = (focusableElements, targetChar) => {
const elementWIthChar = Array.prototype.find.call(
focusableElements,
(element) => {
const dataText = element.dataset.indexText;
return dataText && dataText[0] === targetChar;
},
);
return elementWIthChar;
};
const checkChar = ({ key, target }) => {
const currentGroup = getFocusableGroup(target);
const foundElementWithIndexChar = findFirstElementByChar(
focusable(currentGroup),
key,
);
if (foundElementWithIndexChar) {
foundElementWithIndexChar.focus();
}
};
const focusFirstInGroup = (focusableElements) => {
focusableElements[0].focus();
};
const focusLastInGroup = (focusableElements) => {
focusableElements[focusableElements.length - 1].focus();
};
const focusNextInGroup = (focusableElements, element) => {
const currentIndex = Array.prototype.indexOf.call(
focusableElements,
element,
);
if (currentIndex === focusableElements.length - 1) {
focusableElements[0].focus();
} else {
focusableElements[currentIndex + 1].focus();
}
};
const focusPreviousInGroup = (focusableElements, element) => {
const currentIndex = Array.prototype.indexOf.call(
focusableElements,
element,
);
if (currentIndex === 0) {
focusableElements[focusableElements.length - 1].focus();
} else {
focusableElements[currentIndex - 1].focus();
}
};
const toggleMenu = (element, state) =>
element.dispatchEvent(
new CustomEvent('toolbar-menu-set-toggle', {
bubbles: false,
detail: {
state,
},
}),
);
const closePopover = (element) =>
element.dispatchEvent(
new CustomEvent('toolbar-popover-close', { bubbles: true }),
);
const openPopover = (element) =>
element.dispatchEvent(
new CustomEvent('toolbar-popover-open', { bubbles: true }),
);
const focusClosestPopoverTrigger = (element) => {
element
.closest('[data-toolbar-popover]')
?.querySelector('[data-toolbar-popover-control]')
?.focus();
};
const focusFirstMenuElement = (element) => {
const elements = focusable(
element
.closest('.toolbar-menu__item')
?.querySelector('.toolbar-menu'),
);
if (elements?.length) {
elements[0].focus();
}
};
const focusFirstPopoverElement = (element) => {
// Zero is always popover trigger.
// And Popover header can be not interactive.
const elements = focusable(
element.closest('[data-toolbar-popover]'),
);
if (elements?.length >= 1) {
elements[1].focus();
}
};
const focusLastPopoverElement = (element) => {
const elements = focusable(
element.closest('[data-toolbar-popover]'),
);
if (elements?.length > 0) {
elements[elements.length - 1].focus();
}
};
const closeNonInteractiveElement = (element) => {
// If we are inside submenus.
if (element.closest('[class*="toolbar-menu--level-"]')) {
const trigger =
element.closest('.toolbar-menu')?.previousElementSibling;
toggleMenu(trigger, false);
trigger.focus();
} else {
closePopover(element);
focusClosestPopoverTrigger(element);
}
};
const openInteractiveElement = (element) => {
// If menu button.
if (element.hasAttribute('data-toolbar-menu-trigger')) {
toggleMenu(element, true);
focusFirstMenuElement(element);
}
// If popover trigger.
if (element.hasAttribute('data-toolbar-popover-control')) {
openPopover(element);
focusFirstPopoverElement(element);
}
};
const closeInteractiveElement = (element) => {
// If menu button.
if (element.hasAttribute('data-toolbar-menu-trigger')) {
if (element.getAttribute('aria-expanded') === 'false') {
closeNonInteractiveElement(element);
} else {
toggleMenu(element, false);
focusFirstMenuElement(element);
}
}
// If popover trigger.
if (element.hasAttribute('data-toolbar-popover-control')) {
openPopover(element);
focusLastPopoverElement(element);
}
};
const arrowsSideControl = ({ key, target }) => {
if (
(key === 'ArrowRight' && !IS_RTL) ||
(key === 'ArrowLeft' && IS_RTL)
) {
if (isInteractive(target)) {
openInteractiveElement(target);
// If also we want to care about expand button.
if (
target.getAttribute('aria-controls') === 'admin-toolbar' &&
target.getAttribute('aria-expanded') === 'false'
) {
target.click();
}
}
} else if (
(key === 'ArrowRight' && IS_RTL) ||
(key === 'ArrowLeft' && !IS_RTL)
) {
if (isInteractive(target)) {
closeInteractiveElement(target);
// If also we want to care about expand button.
if (
target.getAttribute('aria-controls') === 'admin-toolbar' &&
target.getAttribute('aria-expanded') !== 'false'
) {
target.click();
}
} else {
closeNonInteractiveElement(target);
}
}
};
const arrowsDirectionControl = ({ key, target }) => {
const focusableElements = focusable(getFocusableGroup(target));
if (key === 'ArrowUp') {
focusPreviousInGroup(focusableElements, target);
} else if (key === 'ArrowDown') {
focusNextInGroup(focusableElements, target);
}
};
sidebar.addEventListener('keydown', (e) => {
switch (e.key) {
case 'Escape':
closePopover(e.target);
focusClosestPopoverTrigger(e.target);
break;
case 'ArrowLeft':
case 'ArrowRight':
e.preventDefault();
arrowsSideControl(e);
break;
case 'ArrowDown':
case 'ArrowUp':
e.preventDefault();
arrowsDirectionControl(e);
break;
case 'Home':
e.preventDefault();
focusFirstInGroup(getFocusableGroup(e.target));
break;
case 'End':
e.preventDefault();
focusLastInGroup(getFocusableGroup(e.target));
break;
default:
checkChar(e);
break;
}
});
},
);
},
};
})(Drupal, once, window.tabbable);

View File

@ -0,0 +1,57 @@
/**
* @file
*
* Element that improves sub-menu UX by implementing the Safe Triangle strategy.
* @see https://www.smashingmagazine.com/2023/08/better-context-menus-safe-triangles
*/
((Drupal, once) => {
/**
* Update CSS variables values for positioning the safe triangle element.
*
* @param {CSSStyleDeclaration} style
* Style property of the parent button.
* @param {number} clientX
* Horizontal position relative to the element.
* @param {number} clientY
* Vertical position relative to the element.
*/
function handleMouseMove({ currentTarget: { style }, clientX, clientY }) {
style.setProperty('--safe-triangle-cursor-x', `${clientX}px`);
style.setProperty('--safe-triangle-cursor-y', `${clientY}px`);
}
/**
* Attaches the safe triangle behavior to all required triggers.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches the safe triangle behavior.
* @prop {Drupal~behaviorDetach} detach
* Removes the safe triangle element.
*/
Drupal.behaviors.safeTriangleInit = {
attach: (context) => {
once('safe-triangle', '[data-has-safe-triangle]', context).forEach(
(button) => {
button.insertAdjacentHTML(
'beforeend',
'<div data-safe-triangle></div>',
);
button.addEventListener('mousemove', handleMouseMove);
},
);
},
detach: (context, settings, trigger) => {
if (trigger === 'unload') {
once
.remove('safe-triangle', '[data-has-safe-triangle]', context)
.forEach((button) => {
button.querySelector('[data-safe-triangle]')?.remove();
button.removeEventListener('mousemove', handleMouseMove);
});
}
},
};
})(Drupal, once);

View File

@ -0,0 +1,155 @@
/**
* @file
*
* Sidebar component.
*
* Only few common things. Like close all popovers when one is opened.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches the behavior to the `.admin-toolbar` element.
*/
(
(Drupal, once) => {
/**
* Drupal behaviors object.
*
* @type {Drupal~behaviors}
*/
Drupal.behaviors.navigation = {
attach(context) {
/**
* Sidebar element with the `.admin-toolbar` class.
*
* @type {HTMLElement}
*/
once('navigation', '.admin-toolbar', context).forEach((sidebar) => {
const backButton = sidebar.querySelector(
'[data-toolbar-back-control]',
);
if (!backButton) {
// We're in layout editing mode and the .admin-toolbar we have in
// scope here is the empty one that only exists to leave space for
// the one added by layout builder. We need to use an empty
// .admin-toolbar element because the css uses the adjacent
// sibling selector.
// @see \navigation_page_top();
return;
}
/**
* All menu triggers.
*
* @type {NodeList}
*/
const buttons = sidebar.querySelectorAll(
'[data-toolbar-menu-trigger]',
);
/**
* All popovers and tooltip triggers.
*
* @type {NodeList}
*/
// const popovers = sidebar.querySelectorAll('[data-toolbar-popover]');
/**
* NodeList of all tooltip elements.
*
* @type {NodeList}
*/
const tooltips = sidebar.querySelectorAll('[data-drupal-tooltip]');
const closeButtons = () => {
buttons.forEach((button) => {
button.dispatchEvent(
new CustomEvent('toolbar-menu-set-toggle', {
detail: {
state: false,
},
}),
);
});
};
const closePopovers = (current = false) => {
// TODO: Find way to use popovers variable.
// This change needed because BigPipe replaces user popover.
sidebar
.querySelectorAll('[data-toolbar-popover]')
.forEach((popover) => {
if (
current &&
current instanceof Element &&
popover.isEqualNode(current)
) {
return;
}
popover.dispatchEvent(
new CustomEvent('toolbar-popover-close', {}),
);
});
};
// Add click event listeners to all buttons and then contains the callback
// to expand / collapse the button's menus.
sidebar.addEventListener('click', (e) => {
if (e.target.matches('button, button *')) {
e.target.closest('button').focus();
}
});
// We want to close all popovers when we close sidebar.
sidebar.addEventListener('toggle-admin-toolbar-content', (e) => {
if (!e.detail.state) {
closePopovers();
}
});
// When any popover opened we close all others.
sidebar.addEventListener('toolbar-popover-toggled', (e) => {
if (e.detail.state) {
closeButtons();
closePopovers(e.target);
}
});
// When any menu opened we close all others.
sidebar.addEventListener('toolbar-menu-toggled', (e) => {
if (e.detail.state) {
// We want to close buttons on when new opened only if they are on same level.
const targetLevel = e.detail.level;
buttons.forEach((button) => {
const buttonLevel = button.dataset.toolbarMenuTrigger;
if (
!button.isEqualNode(e.target) &&
+buttonLevel === +targetLevel
) {
button.dispatchEvent(
new CustomEvent('toolbar-menu-set-toggle', {
detail: {
state: false,
},
}),
);
}
});
}
});
backButton.addEventListener('click', closePopovers);
// Tooltips triggered on hover and focus so add an extra event listener
// to close all popovers.
tooltips.forEach((tooltip) => {
['mouseover', 'focus'].forEach((e) => {
tooltip.addEventListener(e, closePopovers);
});
});
});
},
};
}
)(Drupal, once);

View File

@ -0,0 +1,53 @@
/* cspell:ignore uidom */
((Drupal, once, { computePosition, offset, shift, flip }) => {
/**
* Attaches the dropdown behavior to all required triggers.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches the dropdown behavior.
*/
Drupal.behaviors.dropdownInit = {
attach: (context) => {
once('dropdown-trigger', '[data-drupal-dropdown]', context).forEach(
(trigger) => {
const dropdown = trigger.nextElementSibling;
const updatePosition = () => {
computePosition(trigger, dropdown, {
strategy: 'fixed',
placement: trigger.dataset.drupalDropdownPosition || 'bottom',
middleware: [
flip({ padding: 16 }),
offset(6),
shift({ padding: 16 }),
],
}).then(({ x, y }) => {
Object.assign(dropdown.style, {
left: `${x}px`,
top: `${y}px`,
});
});
};
trigger.addEventListener('click', (e) => {
updatePosition();
trigger.setAttribute(
'aria-expanded',
e.currentTarget.getAttribute('aria-expanded') === 'false',
);
});
// Event listener to close dropdown when clicking outside
document.addEventListener('click', (e) => {
const isButtonClicked = trigger.contains(e.target);
if (!isButtonClicked) {
trigger.setAttribute('aria-expanded', 'false');
}
});
},
);
},
};
})(Drupal, once, FloatingUIDOM);

View File

@ -0,0 +1,135 @@
/**
*
* Toolbar menu code.
*
* Toggle own state
* Listens toolbar-menu-set-toggle to change state.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
*/
(
(Drupal, once) => {
/**
* Constant for the "toolbar-menu-set-toggle" event name.
*
* @type {string}
*/
const TOOLBAR_MENU_SET_TOGGLE = 'toolbar-menu-set-toggle';
/**
* Initializes menu buttons.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Toggles aria-expanded attribute.
* Changes buttons inner text.
* Listens event when should be expanded.
*/
Drupal.behaviors.navigationProcessToolbarMenuTriggers = {
attach: (context) => {
once(
'toolbar-menu-trigger',
'[data-toolbar-menu-trigger]',
context,
).forEach((button) => {
const menu = button.nextElementSibling;
/**
* Element containing the button text.
*
* @type {HTMLElement}
*/
const text = button.querySelector('[data-toolbar-action]');
/**
* Toggles the button's aria-expanded attribute and updates its text.
* This is only one function which change state of button.
*
* @param {boolean} state The button state it should be expanded or collapsed.
*/
const toggleButtonState = (state) => {
button.setAttribute('aria-expanded', state);
if (text) {
text.textContent = state
? Drupal.t('Collapse')
: Drupal.t('Extend');
}
if (state) {
menu.removeAttribute('inert');
} else {
menu.setAttribute('inert', true);
}
};
button.addEventListener('click', (e) => {
const level = e.currentTarget.dataset.toolbarMenuTrigger;
const state =
e.currentTarget.getAttribute('aria-expanded') === 'false';
toggleButtonState(state);
button.dispatchEvent(
new CustomEvent('toolbar-menu-toggled', {
bubbles: true,
detail: {
state,
level,
},
}),
);
});
// State of submenu button can be changed by CustomEvent.
button.addEventListener(TOOLBAR_MENU_SET_TOGGLE, (e) => {
const newState = e.detail.state;
toggleButtonState(newState);
});
});
},
};
/**
* Initializes menu links.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
*
* When current url it adds classes and dispatch event to popover.
*/
Drupal.behaviors.navigationProcessToolbarMenuLinks = {
attach: (context) => {
once(
'toolbar-menu-link',
'a.toolbar-menu__link, a.toolbar-button',
context,
).forEach((link) => {
// What we do if menu link is in current url.
if (document.URL === link.href) {
link.classList.add('current', 'is-active');
link.dispatchEvent(
new CustomEvent('toolbar-active-url', {
bubbles: true,
}),
);
// We also want to open all parent menus.
const menu = link.closest('.toolbar-menu');
if (menu) {
menu.previousElementSibling.dispatchEvent(
new CustomEvent(TOOLBAR_MENU_SET_TOGGLE, {
detail: {
state: true,
},
}),
);
}
}
});
},
};
}
)(Drupal, once);

View File

@ -0,0 +1,153 @@
/**
*
* Toolbar popover.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
*/
const POPOVER_OPEN_DELAY = 150;
const POPOVER_CLOSE_DELAY = 400;
const POPOVER_NO_CLICK_DELAY = 500;
((Drupal, once) => {
Drupal.behaviors.navigationProcessPopovers = {
/**
* Attaches the behavior to the context element.
*
* @param {HTMLElement} context The context element to attach the behavior to.
*/
attach: (context) => {
once(
'toolbar-popover',
context.querySelectorAll('[data-toolbar-popover]'),
).forEach((popover) => {
// This is trigger of popover. Currently only first level button.
const button = popover.querySelector('[data-toolbar-popover-control]');
// This is tooltip content. Currently child menus only.
const tooltip = popover.querySelector('[data-toolbar-popover-wrapper]');
if (!button || !tooltip) return;
const expandPopover = () => {
popover.classList.add('toolbar-popover--expanded');
button.dataset.drupalNoClick = 'true';
tooltip.removeAttribute('inert');
setTimeout(() => {
delete button.dataset.drupalNoClick;
}, POPOVER_NO_CLICK_DELAY);
};
const collapsePopover = () => {
popover.classList.remove('toolbar-popover--expanded');
tooltip.setAttribute('inert', true);
delete button.dataset.drupalNoClick;
};
/**
* We need to change state of trigger and popover.
*
* @param {boolean} state The popover state.
*
* @param {boolean} initialLoad Happens on page loads.
*/
const toggleState = (state, initialLoad = false) => {
/* eslint-disable-next-line no-unused-expressions */
state && !initialLoad ? expandPopover() : collapsePopover();
button.setAttribute('aria-expanded', state && !initialLoad);
const text = button.querySelector('[data-toolbar-action]');
if (text) {
text.textContent = state
? Drupal.t('Collapse')
: Drupal.t('Extend');
}
// Dispatch event to sidebar.js
popover.dispatchEvent(
new CustomEvent('toolbar-popover-toggled', {
bubbles: true,
detail: {
state,
},
}),
);
};
const isPopoverHoverOrFocus = () =>
popover.contains(document.activeElement) || popover.matches(':hover');
const delayedClose = () => {
setTimeout(() => {
if (isPopoverHoverOrFocus()) return;
// eslint-disable-next-line no-use-before-define
close();
}, POPOVER_CLOSE_DELAY);
};
const open = () => {
['mouseleave', 'focusout'].forEach((e) => {
button.addEventListener(e, delayedClose, false);
tooltip.addEventListener(e, delayedClose, false);
});
};
const close = () => {
toggleState(false);
['mouseleave', 'focusout'].forEach((e) => {
button.removeEventListener(e, delayedClose);
tooltip.removeEventListener(e, delayedClose);
});
};
button.addEventListener('mouseover', () => {
// This is not needed because no hover on mobile.
// @todo test is after.
if (window.matchMedia('(max-width: 1023px)').matches) {
return;
}
setTimeout(() => {
// If it is accident hover ignore it.
// If in this timeout popover already opened by click.
if (
!button.matches(':hover') ||
!button.getAttribute('aria-expanded') === 'false'
) {
return;
}
toggleState(true);
open();
}, POPOVER_OPEN_DELAY);
});
button.addEventListener('click', (e) => {
const state =
e.currentTarget.getAttribute('aria-expanded') === 'false';
if (!e.currentTarget.dataset.drupalNoClick) {
toggleState(state);
}
});
// Listens events from sidebar.js.
popover.addEventListener('toolbar-popover-close', () => {
close();
});
// TODO: Add toggle with state.
popover.addEventListener('toolbar-popover-open', () => {
toggleState(true);
});
// Listens events from toolbar-menu.js
popover.addEventListener('toolbar-active-url', () => {
toggleState(true, true);
});
});
},
};
})(Drupal, once);

View File

@ -0,0 +1,83 @@
/* cspell:ignore uidom */
/**
* @file
*
* Simple tooltip component.
*
* To use it just add:
*
* data-drupal-tooltip="title" - Text displayed in tooltip.
*
* data-drupal-tooltip-class="extra-class" - Optional class for css.
*
* data-drupal-tooltip-position="top" - Tooltip position (default right).
*
* @see https://floating-ui.com/ for available placement options.
*/
((Drupal, once, { computePosition, offset, shift, flip }) => {
/**
* Theme function for a tooltip.
*
* @param {object} dataset
* The dataset object.
* @param {string} dataset.drupalTooltipClass
* Extra class for theming.
* @param {string} dataset.drupalTooltip
* The text for tooltip.
*
* @return {HTMLElement}
* A DOM Node.
*/
Drupal.theme.tooltipWrapper = (dataset) =>
`<div class="toolbar-tooltip ${dataset.drupalTooltipClass || ''}">
${dataset.drupalTooltip}
</div>`;
/**
* Attaches the tooltip behavior to all required triggers.
*
* @type {Drupal~behavior}
*
* @prop {Drupal~behaviorAttach} attach
* Attaches the tooltip behavior.
*/
Drupal.behaviors.tooltipInit = {
attach: (context) => {
once('tooltip-trigger', '[data-drupal-tooltip]', context).forEach(
(trigger) => {
trigger.insertAdjacentHTML(
'afterend',
Drupal.theme.tooltipWrapper(trigger.dataset),
);
const tooltip = trigger.nextElementSibling;
const updatePosition = () => {
computePosition(trigger, tooltip, {
strategy: 'fixed',
placement: trigger.dataset.drupalTooltipPosition || 'right',
middleware: [
flip({ padding: 16 }),
offset(6),
shift({ padding: 16 }),
],
}).then(({ x, y }) => {
Object.assign(tooltip.style, {
left: `${x}px`,
top: `${y}px`,
});
});
};
// Small trick to avoid tooltip stays on same place when button size changed.
const ro = new ResizeObserver(updatePosition);
ro.observe(trigger);
trigger.addEventListener('mouseover', updatePosition);
trigger.addEventListener('focus', updatePosition);
},
);
},
};
})(Drupal, once, FloatingUIDOM);