Menu mobile
Le composant `menu-mobile` permet d'afficher un menu qui s'ouvre sous la forme d'un panel.
Il prend en paramètres les éléments suivants :
menu: L'objet Menu à afficherposition: la position du menu (top, left, right, bottom)backLabel: le libellé du bouton de retourbackdrop: modificateur pour le backdrop (voir tag panel)
Exemples
<kuik:menu-mobile closeIconTitle="Fermer le menu" menu="${menuObject}" position="right" backLabel="Retour" backdrop="blur" />
menu étant du type fr.kosmos.web.kore.attributes.Menu.
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" trimDirectiveWhitespaces="true" %>
<%@ taglib prefix="kuik" uri="kuik" %>
<%--@elvariable id="menuMobileViewModel" type="fr.kosmos.web.kore.attributes.interfaces.Menu"--%>
<kuik:button popovertarget="menu-mobile1" icon="true"><kuik:icon title="menu" source="icon://ui/menu" size="md"/></kuik:button>
<kuik:menu-mobile closeIconTitle="Fermer le menu" id="menu-mobile1" menu="${menuMobileViewModel}" backLabel="En arrière" backdrop="dark blur" class="panel--skin-a" style="--color-scheme: only dark;"/>
<kuik:button popovertarget="menu-mobile2" icon="true"><kuik:icon title="menu" source="icon://ui/menu" size="md"/></kuik:button>
<kuik:menu-mobile closeIconTitle="Fermer le menu" id="menu-mobile2" menu="${menuMobileViewModel}" backLabel="En arrière" backdrop="dark blur" class="panel--skin-a" style="--color-scheme: only light;" />
<kuik:button popovertarget="menu-mobile3" icon="true"><kuik:icon title="menu" source="icon://ui/menu" size="md"/></kuik:button>
<kuik:menu-mobile closeIconTitle="Fermer le menu" id="menu-mobile3" menu="${menuMobileViewModel}" backLabel="En arrière" backdrop="dark blur" class="panel--skin-a" style="--color-scheme: light dark;" />
| Name | Type | Required | Description |
|---|---|---|---|
| menu | fr.kosmos.web.kore.attributes.interfaces.Menu | true | Information du menu |
| id | java.lang.String | true | Identifiant du menu |
| size | java.lang.String | false | Taille (défaut : lg) |
| position | java.lang.String | false | Position (défaut: left) |
| backLabel | java.lang.String | false | Libellé du bouton de retour vers l'élément précédent |
| backdrop | java.lang.String | false | Modification du backdrop (voir tag panel) |
| headerView | java.lang.String | false | Vue du header |
| footerView | java.lang.String | false | Vue du footer |
| displayCloseIcon | java.lang.Boolean | false | Afficher l'icone de fermeture du menu |
| closeIconTitle | java.lang.String | false | Le libellé du bouton de fermeture |
// CONSTANTS
const EMPTY_STR = '';
const HASH_CHAR = '#';
const HREF_ATTR = 'href';
const CLICK_EVENT = 'click';
const BEFORE_TOGGLE_EVENT = 'beforetoggle';
const TOGGLE_EVENT = 'toggle';
const SCROLLEND_EVENT = 'scrollend';
const TAB_INDEX_ATTR = 'tabindex';
const DISABLED_TAB_INDEX_VALUE = '-1';
const HASH_START_REGEX = /^.*#/;
const TARGET_EVENT_STATUS_OPEN = 'open';
const DATA_ATTACH_TO_PANEL_ATTR = 'data-attach-to-panel';
const SCOPE_SELECTOR = ':scope';
const JS_MENU_WRAPPER_SELECTOR = '.js-menu-list';
const SCOPE_JS_MENU_WRAPPER_SELECTOR = `${SCOPE_SELECTOR} > .panel__body .js-menu-list`;
const SCOPE_JS_CURRENT_MENU_SELECTOR = '[aria-current="page"]';
const MENU_LIST_SELECTOR = '.menu-list';
const MENU_LIST_CURRENT_CLASSNAME = 'menu-list--current';
const MENU_LIST_ITEM_SELECTOR = '.menu-list__item';
const MENU_LIST_LINK_SELECTOR = '.menu-list__link';
const MENU_LIST_LINK_BACK_CLASSNAME = 'menu-list__link--back';
const PANEL_BODY = '.panel__body';
const SCOPE_PANEL_BODY = `${SCOPE_SELECTOR} > .panel__body`;
const PANEL_BODY_SMOOTH_SCROLL_CLASSNAME = 'panel__body--smooth-scroll';
const SUBMENU_ITEMS_SELECTOR = `[${HREF_ATTR}^="${HASH_CHAR}"]:not([${HREF_ATTR}="${HASH_CHAR}"])`;
const SCOPE_LIST_ITEM_SELECTOR = `${SCOPE_SELECTOR} > ${MENU_LIST_ITEM_SELECTOR}`;
const SCOPE_MENU_SELECTOR = `${SCOPE_SELECTOR} > ${MENU_LIST_SELECTOR}`;
const ONSCROLLEND_EVENTKEY = `on${SCROLLEND_EVENT}`;
const TARGET_ATTR_SELECTOR = id => `[${HREF_ATTR}="${HASH_CHAR}${id}"]`;
const PX = value => `${value}px`;
// VARIABLES
// Utilisée pour stocker l'URL de la page, afin de pouvoir la restaurer quand le menu est fermé
let _previousUrl
/**
* Renvoie tous les éléments "focusable" à l'intérieur de l'élément.
* NOTE : cette fonction ne prend VOLONTAIREMENT pas en compte la présence d'un éventuel attribut [tabindex="-1"] puisqu'elle est utilisée
* dans l'objectif de contrôler la navigation au clavier pour l'accessibilité du menu.
* @param {HTMLElement} element Élément HTML parent
* @returns {Array} Tableau d'éléments HTML
*/
function findFocusableChildrenOf(element) {
return Array.from(element.querySelectorAll(`
input:not([disabled]),
select:not([disabled]),
textarea:not([disabled]),
button:not([disabled]),
a[href]:not([disabled]),
[tabindex]:not([disabled])
`) ?? [ ]);
}
/**
* Désactive la navigation au clavier sur tous les éléments cliquables du menu qui ne sont pas dans le menu affiché
* @param {HTMLElement} menu Menu de référence
*/
function disableAllItemsExceptFor(menu) {
const focusables = findFocusableChildrenOf(menu.closest(JS_MENU_WRAPPER_SELECTOR));
for (const focusable of focusables) {
const parentMenu = focusable.closest(MENU_LIST_SELECTOR);
if (parentMenu === menu) {
focusable.removeAttribute(TAB_INDEX_ATTR);
} else {
focusable.setAttribute(TAB_INDEX_ATTR, DISABLED_TAB_INDEX_VALUE);
}
}
}
/**
* Assigne le focus à un élément du menu.
* Si le paramètre "id" n'est pas défini, alors ce sera le premier élément cliquable du menu qui recevra le focus.
* S'il est défini, "id" N'indique PAS l'identifiant de l'item qui doit être sélectionné MAIS l'identifiant de sa cible. Cela permet de
* retrouver un item dans la liste et de le cibler quand on sort de son sous-menu.
* @param {HTMLElement} menu Menu à utiliser
* @param {String} id Identifiant qui sera utilisé pour le ciblage
*/
function setFocusToCurrentMenu(menu, id = undefined) {
let focusable = null;
// Liste tous les sous-items directs
const menuItems = menu.querySelectorAll(SCOPE_LIST_ITEM_SELECTOR);
for (const menuItem of menuItems) {
// Récupère le sous-menu s'il est présent
const subMenuElement = menuItem.querySelector(SCOPE_MENU_SELECTOR);
// Récupère la liste des éléments de l'item qui peuvent avoir le focus, hors sous-menu
const focusables = findFocusableChildrenOf(menuItem).filter(e => !subMenuElement || !(subMenuElement.contains(e)));
// Si l'id est défini, on récupère l'élément focusable qui lui correspond, sinon, on prend le premier qu'on trouve
[ focusable ] = id ? [ focusables.find(i => i.matches(TARGET_ATTR_SELECTOR(id))) ] : focusables;
if (focusable) {
break;
}
}
// On applique le focus à l'élément focusable récupéré s'il n'est pas déjà ailleurs
if (focusable && document.activeElement === document.body) {
focusable.focus({ preventScroll: true });
}
}
/**
* Renvoie le menu ciblé par un item.
* @param {HTMLElement} item Item du menu à utiliser
* @returns {HTMLElement} Menu ciblé
*/
function retrieveTargetedMenu(item) {
let targetedMenu = null;
const idSelector = item.getAttribute(HREF_ATTR)?.replace(HASH_START_REGEX, EMPTY_STR);
if (idSelector) {
targetedMenu = document.getElementById(idSelector);
}
return targetedMenu;
}
/**
* Fonction qui affiche le bon menu et donne le focus à l'item souhaité
* @param {HTMLElement} menu Menu ciblé
* @param {String} id Identifiant du menu ciblé
* @param {boolean} immediate Indique s'il faut afficher le menu directement ou attendre la fin de l'animation
* @returns {Promise} Possibilité d'attendre la fin du scroll horizontal pour faire autre chose
*/
function setCurrentMenu(menu, id = undefined, immediate = false) {
return new Promise((resolve) => {
// Remet tous les tabindex du menu à -1 sauf pour le menu courant
disableAllItemsExceptFor(menu);
const panelBody = menu.closest(PANEL_BODY);
// Désigne le menu courant par une classe
const menus = Array.from(panelBody.querySelectorAll(MENU_LIST_SELECTOR));
for (const m of menus) {
m.classList.toggle(MENU_LIST_CURRENT_CLASSNAME, m === menu);
}
const shouldListenToScrollEvent = ONSCROLLEND_EVENTKEY in panelBody && !immediate;
// Donne le focus à l'élément souhaité dans le menu
// Ne le fait que quand on est sûr que l'animation de scroll est terminée (ou si la demande est immédiate)
if (shouldListenToScrollEvent) {
panelBody.addEventListener(SCROLLEND_EVENT, () => {
setFocusToCurrentMenu(menu, id);
resolve();
}, { once: true });
}
// Assure que le menu courant est bien en place
scrollToMenu(menu);
if (!shouldListenToScrollEvent) {
setFocusToCurrentMenu(menu, id);
resolve();
}
});
}
/**
* Fonction exécutée lorsqu'un élément est cliqué (fonctionne aussi quand la touche Entrée est appuyée si l'élément a le focus).
* @param {MouseEvent} e Événement JS
*/
async function itemClickEventHandler(e) {
let id = '';
const target = e.target.closest(MENU_LIST_LINK_SELECTOR);
// Traitement spécifique si on clique sur l'item de Retour qui permet de revenir au niveau parent
const isBackItem = target.classList.contains(MENU_LIST_LINK_BACK_CLASSNAME);
if (isBackItem) {
id = target.closest(MENU_LIST_SELECTOR)?.id;
}
// Récupère le menu ciblé
const menu = retrieveTargetedMenu(target);
if (menu) {
// Empêche le changement de hash dans l'URL de la page
e.preventDefault();
e.stopPropagation();
// Affiche le bon menu et donne le focus à l'item souhaité
await setCurrentMenu(menu, id);
}
}
/**
* Ajoute le contrôle du clic sur les items du menu et ses sous-items.
* @param {HTMLElement} menu Menu à utiliser
*/
function bindClicksOnItems(menu) {
const submenuItems = menu.querySelectorAll(SUBMENU_ITEMS_SELECTOR);
for (const submenuItem of submenuItems) {
submenuItem.addEventListener(CLICK_EVENT, itemClickEventHandler);
}
}
/**
* Retire les contrôles ajoutés aux items du menu.
* @param {HTMLElement} menu Menu à utiliser
*/
function unbindClicksOnItems(menu) {
const submenuItems = menu.querySelectorAll(SUBMENU_ITEMS_SELECTOR);
for (const submenuItem of submenuItems) {
submenuItem.removeEventListener(CLICK_EVENT, itemClickEventHandler);
}
}
/**
* Événement exécuté avant que le panneau ne s'ouvre ou se ferme
* @param {ToggleEvent} e Événement JS
*/
function beforePanelToggleHandler(e) {
const panel = e.target;
const menu = panel.querySelector(SCOPE_JS_MENU_WRAPPER_SELECTOR);
const panelBody = panel.querySelector(SCOPE_PANEL_BODY);
if (!panelBody) return;
if (e.newState === TARGET_EVENT_STATUS_OPEN) {
_previousUrl = document.location.href
bindClicksOnItems(menu);
} else {
if (_previousUrl) {
history.pushState({ }, EMPTY_STR, _previousUrl);
_previousUrl = undefined;
}
unbindClicksOnItems(menu);
panel.querySelectorAll('.menu-list--current').forEach((currentLink) => {
currentLink.classList.remove('menu-list--current');
});
}
}
/**
* Événement exécuté après l'ouverture ou la fermeture du panneau
* @param {ToggleEvent} toggleEvent Événement JS
*/
async function afterPanelToggleHandler(toggleEvent) {
const panel = toggleEvent.target;
const menu = panel.querySelector(SCOPE_JS_CURRENT_MENU_SELECTOR)?.closest('.menu-list') || panel.querySelector(SCOPE_JS_MENU_WRAPPER_SELECTOR);
if (toggleEvent.newState === TARGET_EVENT_STATUS_OPEN) {
const durationNormal = Number.parseFloat(window.getComputedStyle(document.querySelector('body')).getPropertyValue('--duration-normal')) * 1000;
if (durationNormal === 0.0) {
await afterPanelToggleSetCurrentMenu(panel, menu);
} else {
handlePanelToggle(panel, menu, durationNormal);
}
}
}
/**
* Gère le basculement d'un panneau avec une transition et met à jour le menu une fois la transition terminée.
* Dans le cas où le transitionend est mal géré par le navigateur, un timeout 10% supérieur à la durée de la transition est mis en place.
*
* @param {HTMLElement} panel Le panneau à basculer.
* @param {HTMLElement} menu Le menu à mettre à jour après le basculement.
* @param {number} durationNormal Durée de la transition en millisecondes.
*/
function handlePanelToggle(panel, menu, durationNormal) {
const controller = new AbortController();
const panelToggleTimeout = setTimeout(async () => {
controller.abort();
await afterPanelToggleSetCurrentMenu(panel, menu);
}, durationNormal * 1.1)
panel.addEventListener('transitionend', async () => await afterPanelToggleSetCurrentMenu(panel, menu, panelToggleTimeout), {once: true, signal: controller.signal});
}
/**
* Met à jour le menu actif après le basculement du panneau.
*
* @param {HTMLElement} panel - Le panneau dont le comportement de défilement est modifié.
* @param {HTMLElement} menu - Le menu à définir comme actif.
*/
async function afterPanelToggleSetCurrentMenu(panel, menu, panelToggleTimeout = null) {
if (panelToggleTimeout) {
clearTimeout(panelToggleTimeout);
}
setScrollBehavior(panel, false);
await setCurrentMenu(menu, undefined, true);
setScrollBehavior(panel, true);
}
/**
* Définit le comportement de défilement pour l'élément de panneau donné.
*
* @param {HTMLElement} panel - L'élément du panneau à configurer.
* @param {boolean} activate - Indique si le défilement fluide doit être activé (true) ou désactivé (false).
*/
function setScrollBehavior(panel, activate) {
const panelBody = panel.querySelector(SCOPE_PANEL_BODY);
if (panelBody) {
panelBody.classList.toggle(PANEL_BODY_SMOOTH_SCROLL_CLASSNAME, activate);
}
}
/**
* Assure que le menu affiché est bien en place
* @param {HTMLElement} menu Menu à positionner
*/
function scrollToMenu(menu) {
const panelBody = menu.closest(PANEL_BODY);
// On change la hauteur du menu pour ne garder que la hauteur nécessaire.
panelBody.style.height = PX(menu.clientHeight);
// On déplace le scroll
const { x } = menu.getBoundingClientRect();
panelBody.scrollTo(x + panelBody.scrollLeft, 0);
}
/**
* Ajoute les contrôles d'événement nécessaires si le menu se trouve dans un composant "Panneau".
* @param {HTMLElement} menu Menu concerné
*/
function bindPanelEvents(menu) {
if (!(menu.hasAttribute(DATA_ATTACH_TO_PANEL_ATTR))) return;
const id = menu.getAttribute(DATA_ATTACH_TO_PANEL_ATTR);
const panel = document.getElementById(id);
panel?.addEventListener(BEFORE_TOGGLE_EVENT, beforePanelToggleHandler);
panel?.addEventListener(TOGGLE_EVENT, afterPanelToggleHandler);
}
/**
* Retire les contrôles d'événements nécessaires quand le menu se trouve dans un élément "Panneau".
* @param {HTMLElement} menu Menu concerné
*/
function unbindPanelEvents(menu) {
if (!(menu.hasAttribute(DATA_ATTACH_TO_PANEL_ATTR))) return;
const id = menu.getAttribute(DATA_ATTACH_TO_PANEL_ATTR);
const panel = document.getElementById(id);
panel?.removeEventListener(BEFORE_TOGGLE_EVENT, beforePanelToggleHandler);
panel?.removeEventListener(TOGGLE_EVENT, afterPanelToggleHandler);
}
/**
* Initialise un menu mobile.
* @param {HTMLElement} menu Menu à initialiser
*/
function initMenuMobile(menu) {
bindPanelEvents(menu);
}
/**
* Détruit la gestion associée à un menu mobile.
* @param {HTMLElement} menu Menu concerné
*/
function destroyMenuMobile(menu) {
unbindPanelEvents(menu);
}
/**
* Initialise tous les menus mobile trouvés sur la page.
*/
function initAllMenuMobile() {
document.querySelectorAll(JS_MENU_WRAPPER_SELECTOR).forEach(initMenuMobile);
}
/**
* Détruit la gestion associée à tous les menus mobile trouvés sur la page.
*/
function destroyAllMenuMobile() {
document.querySelectorAll(JS_MENU_WRAPPER_SELECTOR).forEach(destroyMenuMobile);
}
export { initAllMenuMobile, initMenuMobile, destroyAllMenuMobile, destroyMenuMobile };
window.addEventListener('load', initAllMenuMobile);
<%@ tag pageEncoding="UTF-8" trimDirectiveWhitespaces="true" dynamic-attributes="dynattrs" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="kuik" uri="kuik" %>
<%@ taglib prefix="resources" uri="resources" %>
<%@ attribute name="menu" required="true" type="fr.kosmos.web.kore.attributes.interfaces.Menu" description="Information du menu" %>
<%@ attribute name="id" required="true" type="java.lang.String" description="Identifiant du menu" %>
<%@ attribute name="size" required="false" type="java.lang.String" description="Taille (défaut : lg)" %>
<%@ attribute name="position" required="false" type="java.lang.String" description="Position (défaut: left)" %>
<%@ attribute name="backLabel" required="false" type="java.lang.String" description="Libellé du bouton de retour vers l'élément précédent" %>
<%@ attribute name="backdrop" required="false" type="java.lang.String" description="Modification du backdrop (voir tag panel)" %>
<%@ attribute name="headerView" required="false" type="java.lang.String" description="Vue du header" %>
<%@ attribute name="footerView" required="false" type="java.lang.String" description="Vue du footer" %>
<%@ attribute name="displayCloseIcon" required="false" type="java.lang.Boolean" description="Afficher l'icone de fermeture du menu" %>
<%@ attribute name="closeIconTitle" required="false" type="java.lang.String" description="Le libellé du bouton de fermeture" %>
<%--@elvariable id="itemViewModel" type="fr.kosmos.web.kore.attributes.interfaces.MenuItem"--%>
<c:set var="classesSize" value="menu-list--lg"/>
<c:if test="${not empty pageScope.size}">
<c:set var="classesSize" value="menu-list--${size}"/>
</c:if>
<c:if test="${empty pageScope.position}">
<c:set var="position" value="left" scope="request"/>
</c:if>
<c:if test="${empty backLabel}">
<c:set var="backLabel" value="Retour" />
</c:if>
<c:set var="styles" value="" />
<c:set var="attributes">
<c:forEach items="${dynattrs}" var="a">
<c:choose>
<c:when test="${a.key == 'class'}">
<c:set value="${classes} ${a.value}" var="classes"/>
</c:when>
<c:when test="${a.key == 'style'}">
<c:set value="${a.value}" var="styles"/>
</c:when>
<c:otherwise>${a.key}="${a.value}" </c:otherwise>
</c:choose>
</c:forEach>
</c:set>
<kuik:panel id="${pageScope.id}" position="${pageScope.position}" modal="true" backdrop="${pageScope.backdrop}" class="panel--no-border panel--no-sticky ${classes}" style="${styles}">
<kuik:panel-header popoverTarget="menu-mobile" class="panel__header--vertical" displayCloseIcon="${pageScope.displayCloseIcon}" closeIconTitle="${pageScope.closeIconTitle}">
<c:if test="${not empty headerView && not empty headerView}">
<jsp:include page="${headerView}"/>
</c:if>
</kuik:panel-header>
<kuik:panel-body class="panel__body--smooth-scroll">
<c:if test="${not empty menu.items}">
<nav>
<ul class="menu-list js-menu-list ${classesSize}" id="${pageScope.id}-top" data-attach-to-panel="menu-mobile">
<c:forEach items="${menu.items}" var="item">
<kuik:sub-menu-mobile subMenu="${item}" backTarget="top" backLabel="${backLabel}" />
</c:forEach>
</ul>
</nav>
</c:if>
</kuik:panel-body>
<c:if test="${not empty footerView && not empty footerView}">
<kuik:panel-footer>
<jsp:include page="${footerView}"/>
</kuik:panel-footer>
</c:if>
</kuik:panel>
<resources:addScript path="/static/kuik/menu-mobile/menu-mobile.js" type="module"/>