diff --git a/build/media_source/mod_menu/css/mod-menu.css b/build/media_source/mod_menu/css/mod-menu.css
new file mode 100644
index 0000000000000..d748b8fcd7ebf
--- /dev/null
+++ b/build/media_source/mod_menu/css/mod-menu.css
@@ -0,0 +1,22 @@
+:where(.mod-menu__toggle-sub) {
+ display: inline-flex;
+ align-items: center;
+ padding: 0;
+ color: currentColor;
+ background-color: transparent;
+ border: none;
+ &[aria-expanded="true"] .icon-chevron-down {
+ transform: rotate(180deg);
+ }
+}
+:where(.mod-menu [class*="icon-"]) {
+ margin-inline-start: .5rem;
+ transition: all .2s, background-color .2s;
+}
+
+:where(.mod-menu__sub[aria-hidden="true"]) {
+ display: none;
+}
+:where(.mod-menu__sub[aria-hidden="false"]) {
+ display: block;
+}
diff --git a/build/media_source/mod_menu/joomla.asset.json b/build/media_source/mod_menu/joomla.asset.json
new file mode 100644
index 0000000000000..2314d4d0ad3d8
--- /dev/null
+++ b/build/media_source/mod_menu/joomla.asset.json
@@ -0,0 +1,44 @@
+{
+ "$schema": "https://developer.joomla.org/schemas/json-schema/web_assets.json",
+ "name": "mod_menu",
+ "version": "6.0.0",
+ "description": "Joomla CMS",
+ "license": "GPL-2.0-or-later",
+ "assets": [
+ {
+ "name": "mod_menu.admin-menu",
+ "type": "script",
+ "uri": "mod_menu/admin-menu.min.js",
+ "dependencies": [
+ "core"
+ ],
+ "attributes": {
+ "type": "module"
+ }
+ },
+ {
+ "name": "mod_menu.menu",
+ "type": "script",
+ "uri": "mod_menu/menu.min.js",
+ "dependencies": [
+ "core"
+ ],
+ "attributes": {
+ "type": "module"
+ }
+ },
+ {
+ "name": "mod_menu.menu",
+ "type": "style",
+ "uri": "mod_menu/mod-menu.min.css"
+ },
+ {
+ "name": "mod_menu.menu",
+ "type": "preset",
+ "dependencies": [
+ "mod_menu.menu#style",
+ "mod_menu.menu#script"
+ ]
+ }
+ ]
+}
diff --git a/build/media_source/mod_menu/js/menu.es6.js b/build/media_source/mod_menu/js/menu.es6.js
index eca867aed5711..17ed88bdddf84 100644
--- a/build/media_source/mod_menu/js/menu.es6.js
+++ b/build/media_source/mod_menu/js/menu.es6.js
@@ -6,140 +6,239 @@
(() => {
'use strict';
- function topLevelMouseOver(el, settings) {
- const ulChild = el.querySelector('ul');
- if (ulChild) {
- ulChild.setAttribute('aria-hidden', 'false');
- ulChild.classList.add(settings.menuHoverClass);
- }
- }
+ /**
+ * Navigation menu
+ *
+ * Example usage:
+ * // Default behavior (uses menuHoverClass = 'show-menu', dir = 'ltr')
+ * new Nav(document.querySelector('.nav'));
+ *
+ * // Override defaults (e.g. custom open-class and RTL support)
+ * new Nav(document.querySelector('.nav'), {
+ * menuHoverClass: 'my-open-class',
+ * dir: 'rtl'
+ * });
+ *
+ * @param {HTMLElement} nav The root
element
+ * @param {Object} [settings] Optional overrides for defaultSettings
+ * @param {string} [settings.menuHoverClass='show-menu'] CSS class to toggle on open submenus
+ * @param {string} [settings.dir='ltr'] Text direction for keyboard nav ('ltr'|'rtl')
+ */
- function topLevelMouseOut(el, settings) {
- const ulChild = el.querySelector('ul');
- if (ulChild) {
- ulChild.setAttribute('aria-hidden', 'true');
- ulChild.classList.remove(settings.menuHoverClass);
- }
- }
+ class Nav {
- function setupNavigation(nav) {
- const settings = {
+ // Default settings for the Nav class
+ static defaultSettings = {
menuHoverClass: 'show-menu',
- dir: 'ltr',
+ dir: 'ltr'
};
- // Set tabIndex to -1 so that top_level_childs can't receive focus until menu is open
- nav.querySelectorAll(':scope > li').forEach((topLevelEl) => {
- const linkEl = topLevelEl.querySelector('a');
- if (linkEl) {
- linkEl.tabIndex = '0';
- linkEl.addEventListener('mouseover', topLevelMouseOver(topLevelEl, settings));
- linkEl.addEventListener('mouseout', topLevelMouseOut(topLevelEl, settings));
- }
- const spanEl = topLevelEl.querySelector('span');
- if (spanEl) {
- if (spanEl.parentNode.nodeName !== 'A') {
- spanEl.tabIndex = '0';
- }
- spanEl.addEventListener('mouseover', topLevelMouseOver(topLevelEl, settings));
- spanEl.addEventListener('mouseout', topLevelMouseOut(topLevelEl, settings));
- }
+ constructor(nav, settings = {}) {
+ this.nav = nav;
- topLevelEl.addEventListener('mouseover', ({ currentTarget }) => {
- const ulChild = currentTarget.querySelector('ul');
- if (ulChild) {
- ulChild.setAttribute('aria-hidden', 'false');
- ulChild.classList.add(settings.menuHoverClass);
- }
- });
+ // read the HTML dir attribute or computed style, or fall back to defaultSettings.dir
+ const browserDir =
+ document.documentElement.getAttribute('dir') || //
+ getComputedStyle(document.documentElement).direction || // CSS direction
- topLevelEl.addEventListener('mouseout', ({ currentTarget }) => {
- const ulChild = currentTarget.querySelector('ul');
- if (ulChild) {
- ulChild.setAttribute('aria-hidden', 'true');
- ulChild.classList.remove(settings.menuHoverClass);
- }
- });
+ Nav.defaultSettings.dir;
+ this.settings = {
+ ...Nav.defaultSettings,
+ ...settings
+ };
- topLevelEl.addEventListener('focus', ({ currentTarget }) => {
- const ulChild = currentTarget.querySelector('ul');
- if (ulChild) {
- ulChild.setAttribute('aria-hidden', 'true');
- ulChild.classList.add(settings.menuHoverClass);
- }
- });
+ // merge defaults, browser‐detected dir, and any explicit overrides in `settings`
+ this.settings = {
+ ...Nav.defaultSettings,
+ dir: settings.dir ?? browserDir, // explicit settings.dir wins, otherwise browserDir
+ ...settings // other overrides (e.g. menuHoverClass)
+ };
+
+ // Unique prefix for this nav instance - needed for the id of submenus and aria-controls
+ this.idPrefix = this.nav?.id ?? `nav-${Math.floor(Math.random() * 100000)}`;
+
+ this.topLevelNodes = this.nav.querySelectorAll(':scope > li');
+
+ this.topLevelNodes.forEach((topLevelEl) => {
+ // get submenu ul elements within topLevelEl
+ const levelChildUls = topLevelEl.querySelectorAll('ul');
+ const ariaControls = [];
+ levelChildUls.forEach((childUl) => {
+ childUl.setAttribute('aria-hidden', 'true');
+ childUl.classList.remove(this.settings.menuHoverClass);
+ childUl.id = `${this.idPrefix}-submenu${Nav.idCounter}`;
+ Nav.idCounter += 1;
+ ariaControls.push(childUl.id);
+ });
- topLevelEl.addEventListener('blur', ({ currentTarget }) => {
- const ulChild = currentTarget.querySelector('ul');
- if (ulChild) {
- ulChild.setAttribute('aria-hidden', 'false');
- ulChild.classList.remove(settings.menuHoverClass);
+ if (levelChildUls.length > 0) {
+ const togglebtn = topLevelEl.querySelector('[aria-expanded]');
+ togglebtn?.setAttribute('aria-controls', ariaControls.join(' '));
}
});
- topLevelEl.addEventListener('keydown', (event) => {
- const keyName = event.key;
- const curEl = event.target;
- const curLiEl = curEl.parentElement;
- const curUlEl = curLiEl.parentElement;
- let prevLiEl = curLiEl.previousElementSibling;
- let nextLiEl = curLiEl.nextElementSibling;
- if (!prevLiEl) {
- prevLiEl = curUlEl.children[curUlEl.children.length - 1];
- }
- if (!nextLiEl) {
- [nextLiEl] = curUlEl.children;
- }
- switch (keyName) {
- case 'ArrowLeft':
- event.preventDefault();
- if (settings.dir === 'rtl') {
- nextLiEl.children[0].focus();
- } else {
- prevLiEl.children[0].focus();
- }
- break;
- case 'ArrowRight':
+ nav.addEventListener('keydown', this.onMenuKeyDown.bind(this));
+ nav.addEventListener('click', this.onClick.bind(this));
+ }
+
+ onMenuKeyDown(event) {
+ const target = event.target.closest('li');
+ if (!target) {
+ return;
+ }
+
+ const subLists = target.querySelectorAll('ul');
+
+ switch (event.key) {
+ case 'ArrowUp':
+ event.preventDefault();
+ this.tabPrev();
+ break;
+ case 'ArrowLeft':
+ event.preventDefault();
+ if (this.settings.dir === 'rtl') {
+ this.tabNext();
+ } else {
+ this.tabPrev();
+ }
+ break;
+ case 'ArrowDown':
+ event.preventDefault();
+ this.tabNext();
+ break;
+ case 'ArrowRight':
+ event.preventDefault();
+ if (this.settings.dir === 'rtl') {
+ this.tabPrev();
+ } else {
+ this.tabNext();
+ }
+ break;
+ case 'Enter':
+ if (event.target.nodeName === 'SPAN' && event.target.parentNode.nodeName !== 'A' && subLists.length > 0) {
event.preventDefault();
- if (settings.dir === 'rtl') {
- prevLiEl.children[0].focus();
- } else {
- nextLiEl.children[0].focus();
- }
- break;
- case 'ArrowUp':
- {
+ this.toggleSubMenu(target, subLists, subLists[0]?.getAttribute('aria-hidden') === 'true');
+ }
+ break;
+ case ' ':
+ case 'Spacebar':
+ if (subLists.length > 0) {
event.preventDefault();
- const parent = curLiEl.parentElement.parentElement;
- if (parent.nodeName === 'LI') {
- parent.children[0].focus();
- } else {
- prevLiEl.children[0].focus();
- }
+ this.toggleSubMenu(target, subLists, subLists[0]?.getAttribute('aria-hidden') === 'true');
+ }
+ break;
+ case 'Escape': {
+ event.preventDefault();
+ const currentTopLevelLi = this.getTopLevelParentLi(event.target);
+ if (!currentTopLevelLi) {
break;
}
- case 'ArrowDown':
- event.preventDefault();
- if (curLiEl.classList.contains('parent')) {
- const child = curLiEl.querySelector('ul');
- if (child != null) {
- const childLi = child.querySelector('li');
- childLi.children[0].focus();
- } else {
- nextLiEl.children[0].focus();
- }
- } else {
- nextLiEl.children[0].focus();
+ const allChildListsFromTopLevelLi = currentTopLevelLi.querySelectorAll('ul');
+ if (allChildListsFromTopLevelLi.length > 0) {
+ this.toggleSubMenu(currentTopLevelLi, allChildListsFromTopLevelLi, false);
+ }
+ // set focus on the top level li child with tabindex
+ currentTopLevelLi.querySelectorAll(':scope > [tabindex]:not([tabindex="-1"]), a, button').forEach((tabElement) => {
+ if (tabElement.hasAttribute(['aria-expanded'])) {
+ tabElement.focus();
}
- break;
- default:
- break;
+ });
+ break;
+ }
+ case 'End': {
+ event.preventDefault();
+ const currentLiList = target.closest('ul')?.querySelectorAll(':scope > li');
+ for (let index = currentLiList.length - 1; index >= 0; index -= 1) {
+ const lastTabbable = currentLiList[index].querySelector(':scope > [tabindex]:not([tabindex="-1"]), a, button');
+ if (lastTabbable) {
+ lastTabbable.focus();
+ return;
+ }
+ }
+ break;
}
+ case 'Home': {
+ event.preventDefault();
+ const firstLi = target.closest('ul')?.querySelector(':scope > li:first-child');
+ if (firstLi) {
+ // set focus on first li child with tabindex within current list
+ firstLi.querySelector(':scope > [tabindex]:not([tabindex="-1"]), a, button')?.focus();
+ }
+ break;
+ }
+ default:
+ break;
+ }
+ }
+
+ onClick(event) {
+ if (!event.target?.hasAttribute('aria-expanded') && !event.target?.closest('[aria-expanded')) {
+ return;
+ }
+ if (event.target?.nodeName === 'A') {
+ return;
+ }
+ if (event.target?.nodeName === 'SPAN' && event.target.parentNode.nodeName === 'A') {
+ return;
+ }
+ const target = event.target.closest('li');
+ const subLists = target?.querySelectorAll('ul');
+ if (subLists && subLists.length > 0) {
+ event.preventDefault();
+ this.toggleSubMenu(target, subLists, subLists[0]?.getAttribute('aria-hidden') === 'true');
+ }
+ }
+
+ toggleSubMenu(target, subLists, open = false) {
+ if (open) {
+ // close all opened submenus before opening the new one
+ const allSubMenus = this.nav.querySelectorAll('ul[aria-hidden="false"]');
+ allSubMenus.forEach((ulChild) => {
+ ulChild.setAttribute('aria-hidden', 'true');
+ ulChild.classList.remove(this.settings.menuHoverClass);
+ this.getTopLevelParentLi(ulChild)?.querySelector(':scope > [aria-expanded]')?.setAttribute('aria-expanded', 'false');
+ });
+ }
+ subLists.forEach((ulChild) => {
+ ulChild.setAttribute('aria-hidden', open ? 'false' : 'true');
+ ulChild.classList.toggle(this.settings.menuHoverClass, open);
});
- });
+ target.querySelector(':scope > [aria-expanded]').setAttribute('aria-expanded', open ? 'true' : 'false');
+ }
+
+ focusTabbable(direction = 1) {
+ const tabbables = Array.from(this.nav.querySelectorAll('[tabindex]:not([tabindex="-1"]), a, button'))
+ .filter((el) => !el.disabled && el.tabIndex >= 0 && el.offsetParent !== null);
+ const currentIndex = tabbables.indexOf(document.activeElement);
+ if (tabbables.length === 0) return;
+ const nextIndex = (currentIndex + direction + tabbables.length) % tabbables.length;
+ tabbables[nextIndex].focus();
+ }
+
+ tabNext() {
+ this.focusTabbable(1);
+ }
+
+ tabPrev() {
+ this.focusTabbable(-1);
+ }
+
+ getTopLevelParentLi(element) {
+ let currentLi = element.closest('li');
+ // this.topLevelNodes is a NodeList of top-level li elements in this nav
+ while (currentLi && !Array.from(this.topLevelNodes).includes(currentLi)) {
+ const parentUl = currentLi.parentElement.closest('ul');
+ currentLi = parentUl ? parentUl.closest('li') : null;
+ }
+ return currentLi; // top-level li or null if not found, or the
+ }
}
+ // static idCounter for unique id generation of submenus
+ Nav.idCounter = 0;
+
+ // Initialize Nav instances for all nav elements on the page
document.addEventListener('DOMContentLoaded', () => {
- document.querySelectorAll('.nav').forEach((nav) => setupNavigation(nav));
+ document.querySelectorAll('.nav').forEach((nav) => new Nav(nav));
});
})();
diff --git a/build/media_source/templates/site/cassiopeia/scss/blocks/_header.scss b/build/media_source/templates/site/cassiopeia/scss/blocks/_header.scss
index f18189a50c80a..d746929b590c6 100644
--- a/build/media_source/templates/site/cassiopeia/scss/blocks/_header.scss
+++ b/build/media_source/templates/site/cassiopeia/scss/blocks/_header.scss
@@ -138,6 +138,9 @@
display: none;
color: $gray-900;
}
+ > [aria-hidden="false"] {
+ display: block;
+ }
}
}
diff --git a/language/en-GB/mod_menu.ini b/language/en-GB/mod_menu.ini
index 01c0ddb9d2ef3..a3e5902b6f126 100644
--- a/language/en-GB/mod_menu.ini
+++ b/language/en-GB/mod_menu.ini
@@ -14,4 +14,6 @@ MOD_MENU_FIELD_TAG_ID_LABEL="Menu Tag ID"
MOD_MENU_FIELD_TARGET_DESC="JavaScript values to position a popup window, eg top=50, left=50, width=200, height=300."
MOD_MENU_FIELD_TARGET_LABEL="Target Position"
MOD_MENU_TOGGLE="Toggle Navigation"
+MOD_MENU_TOGGLE_SUBMENU_LABEL="More about: %s"
MOD_MENU_XML_DESCRIPTION="This module displays a menu on the Frontend."
+
diff --git a/modules/mod_menu/tmpl/default.php b/modules/mod_menu/tmpl/default.php
index 415e67568dd23..6c463a69de464 100644
--- a/modules/mod_menu/tmpl/default.php
+++ b/modules/mod_menu/tmpl/default.php
@@ -11,16 +11,15 @@
defined('_JEXEC') or die;
use Joomla\CMS\Helper\ModuleHelper;
+use Joomla\CMS\Language\Text;
/** @var Joomla\CMS\WebAsset\WebAssetManager $wa */
$wa = $app->getDocument()->getWebAssetManager();
-$wa->registerAndUseScript('mod_menu', 'mod_menu/menu.min.js', [], ['type' => 'module']);
+$wa->getRegistry()->addExtensionRegistryFile('mod_menu');
+$wa->usePreset('mod_menu.menu');
-$id = '';
-
-if ($tagId = $params->get('tag_id', '')) {
- $id = ' id="' . htmlspecialchars($tagId, ENT_QUOTES, 'UTF-8') . '"';
-}
+$tagId = $params->get('tag_id', '') ?: 'mod-menu' . $module->id;
+$id = ' id="' . htmlspecialchars($tagId, ENT_QUOTES, 'UTF-8') . '"';
// The menu class is deprecated. Use mod-menu instead
?>
@@ -63,6 +62,12 @@
echo '- ';
+ // The next item is deeper - add toggle only here it is a heading or separator
+ if ($item->deeper && $item->level === 1 && in_array($item->type, ['separator', 'heading'])) {
+ // Add a toggle button.
+ echo '';
+ break;
+
+ default:
+ echo '';
+ }
+ }
echo '