UNPKG

@govbr-ds/core

Version:

Padrão Digital de Governo

573 lines (514 loc) 18.9 kB
/** Classe para instanciar um objeto BRMenu*/ class BRMenu { /** * Instancia do objeto * @param {string} name - Nome do componente em minúsculo * @param {object} component - Objeto referenciando a raiz do componente DOM */ constructor(name, component) { this.name = name this.component = component this.id = this.component.id this.breakpoints = this.component.dataset.breakpoints ? this.component.dataset.breakpoints.split(' ') : ['col-sm-4', 'col-lg-3'] this.pushShadow = 'shadow-lg-right' this.trigger = document.querySelector(`[data-target="#${this.id}"]`) this.contextual = this.component.querySelector('[data-toggle="contextual"]') this.dismiss = this.component.querySelectorAll('[data-dismiss="menu"]') this.scrim = this.component.querySelector('.menu-scrim') this.componentFolders = this.component.querySelectorAll('.menu-folder') this.componentSiders = this.component.querySelectorAll('.side-menu') this.componentItems = this.component.querySelectorAll('.menu-item') this.elementOpenMenu = HTMLElement this.inSubmenu = false this.triggerParent = HTMLElement this._setBehavior() } /** * Define comportamentos do componente * @private */ _setBehavior() { this._toggleMenu() this._setDropMenu() this._setSideMenu() this._setKeyboardBehaviors() this._setBreakpoints() this._setView() this._addARIAAttributes() window.addEventListener('resize', () => { this._setView() }) } /** * Define visual do componente * @private */ _setView() { const template = document.querySelector('body') const menuContextual = document.querySelector('.menu-trigger') // const panel = document.querySelector('.menu-panel') if (menuContextual && window.innerWidth < 992) { template.classList.add('mb-5') } else { template.classList.remove('mb-5') } } /** * Define breakpoints do menu * @private */ _setBreakpoints() { if (!this.component.classList.contains('push') && !this.contextual) { this.component.querySelector('.menu-panel').classList.add(...this.breakpoints) } } /** * Define ações do teclado * @private */ _setKeyboardBehaviors() { // Fechar com tecla ESC this.component.addEventListener('keydown', (event) => { // Código da tecla const keyCode = event.code switch (keyCode) { case 'Escape': event.preventDefault() if (this.trigger) { this._closeMenu() } break case 'ArrowDown': event.preventDefault() this._navigateToNextElment(event.target, 1) break case 'ArrowUp': event.preventDefault() this._navigateToNextElment(event.target, -1) break default: break } }) // Fechar com Tab fora do menu if (this.scrim) { // this.scrim.addEventListener('keyup', () => { // return this._closeMenu() // }) } } /** * Navega para o próximo elemento na lista com base em um operador. * * @param {HTMLElement} element - O elemento de referência a partir do qual a navegação será realizada. * @param {number} operator - Um operador numérico que indica a direção da navegação. * Um valor positivo indica a navegação para baixo, enquanto um valor negativo * indica a navegação para cima. */ _navigateToNextElment(element, operator) { // Obtém o contêiner pai com base na hierarquia const parentFolder = element.parentNode.closest('.side-menu.active') ? element.parentNode.closest('.side-menu.active') : element.closest('.br-menu') // Obtém todos os elementos irmãos relacionados ao elemento de referência dentro do contêiner pai const elementSiblings = parentFolder.classList.contains('br-menu') || parentFolder.classList.contains('menu-body') ? parentFolder.querySelectorAll( '.menu-body > .menu-item, .menu-body > .menu-folder > .menu-item,.menu-body > .menu-folder.active > .side-menu.active, .menu-body > .menu-folder.active > ul > li > .menu-item' ) : parentFolder.querySelectorAll('.side-menu.active > .menu-item,.side-menu.active > ul > li > .menu-item') // Determina a posição do elemento de referência na lista de elementos irmãos const posicao = Array.from(elementSiblings).findIndex((el) => { return el === element }) // Calcula a nova posição na lista com base no operador const soma = posicao + operator // Foca no próximo elemento na lista, ajustando para o início ou o final da lista se necessário if (soma >= 0 && soma < elementSiblings.length) { const nextElement = elementSiblings[soma] if (nextElement.getAttribute('role') === 'group' || nextElement.getAttribute('role') === 'tree') { const nextSibling = elementSiblings[soma + operator] nextSibling.focus() } else { nextElement.focus() } } else { // Se a nova posição estiver fora dos limites, foca no primeiro ou no último elemento da lista, dependendo do operador const lastIndex = elementSiblings.length - 1 const targetElement = operator === 1 ? 0 : lastIndex const target = elementSiblings[targetElement] target.focus() } } /** * Define comportamentos de abrir/fechar menu * @private */ _toggleMenu() { const trigger = this.contextual ? this.contextual : this.trigger // Clicar no trigger if (trigger) { trigger.addEventListener('keydown', (event) => { if (event.code === 'Enter' || event.code === 'Space') { event.preventDefault() // Impede o comportamento padrão do botão Enter ou Space // Fechar Menu caso esteja aberto if (this.component.classList.contains('active')) { this._closeMenu() } else { // Abre Menu this._openMenu() this._focusOnFirstVisibleItem() } } }) trigger.addEventListener('click', () => { // Fechar Menu caso esteja aberto if (this.component.classList.contains('active')) { this._closeMenu() } else { this._openMenu() this._focusOnFirstVisibleItem() } }) } // Clicar no dismiss for (const close of this.dismiss) { close.addEventListener('click', () => { return this._closeMenu() }) } } /** * Focar no primeiro item de nível 1 visível * @private */ _focusOnFirstVisibleItem() { const activeMenu = this.component.querySelector('.menu-body .menu-item:not([hidden]):not(.inactive)') if (activeMenu) { activeMenu.focus() activeMenu.scrollIntoView({ block: 'nearest' }) // Foca e traz para a visualização se necessário return } const firstVisibleItem = this.component.querySelector('.menu-body > .menu-item:not([hidden]):not(.inactive)') if (firstVisibleItem) { firstVisibleItem.focus() firstVisibleItem.scrollIntoView({ block: 'nearest' }) // Foca e traz para a visualização se necessário } } /** * Define visual do menu aberto * @private */ _openMenu() { this.elementOpenMenu = document.activeElement this.component.classList.add('active') this.component.setAttribute('aria-expanded', 'true') if (this.component.classList.contains('push')) { this.component.classList.add(...this.breakpoints, 'px-0') } } /** * Define visual do menu fechado * @private */ _closeMenu() { this.component.classList.remove('active') if (this.component.classList.contains('push')) { this.component.classList.remove(...this.breakpoints, 'px-0') } } /** * Configura Drop Menu para filho imediato de ".menu-folder" * @private */ _setDropMenu() { for (const item of this.component.querySelectorAll('.menu-folder > a.menu-item')) { // Inclui ícone de Drop Menu this._createIcon(item, 'fa-chevron-down') // Configura como Drop Menu item.parentNode.classList.add('drop-menu') //Configura aria indicando que submenu está fechado item.setAttribute('aria-expanded', 'false') // Inicializa Drop Menu this._handleMenuInteraction(item) } } /** * Foca no próximo elemento * @private */ _focusNextElement() { //lista de elementos que desejamos focar const focussableElements = 'a:not([disabled]), button:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])' if (document.activeElement) { const focussable = Array.prototype.filter.call(this.component.querySelectorAll(focussableElements), (element) => { // testa a visibilidade e inclui o elemento ativo return element.offsetWidth > 0 || element.offsetHeight > 0 || element === document.activeElement }) const index = focussable.indexOf(document.activeElement) const nextElement = focussable[index + 1] || focussable[0] nextElement.focus() } } /** * Configura Side Menu para quem não for filho imediato de ".menu-folder" * @private */ _setSideMenu() { for (const ul of this.component.querySelectorAll('a.menu-item + ul')) { if (!ul.parentNode.classList.contains('menu-folder')) { // Inclui ícone de Side Menu this._createIcon(ul.previousElementSibling, 'fa-angle-right') // Configura como Side Menu ul.parentNode.classList.add('side-menu') ul.parentNode.setAttribute('role', 'none') // Inicializa Side Menu this._handleSideMenuInteraction(ul.previousElementSibling) } } } /** * Gerencia eventos de cliques e interações por teclado no menu - tecla espaço * @private * @param {object} element - referência ao Objeto que fará a ação */ _handleMenuInteraction(element) { if (!element.hasAttribute('data-click-listener')) { element.addEventListener('click', () => { this._toggleDropMenu(element) }) element.addEventListener('keydown', (event) => { const menuFolder = element.closest('.menu-folder') const menuItem = menuFolder.querySelector('a.menu-item') if (menuFolder) { if (event.key === ' ' || event.key === 'Spacebar') { if (menuItem && menuItem.classList.contains('focus-visible')) { event.preventDefault() this._toggleDropMenu(element) } } if (event.key === '2') { // event.preventDefault() this._toggleDropMenu(element) } } }) element.setAttribute('data-click-listener', 'true') } } /** * Muda estado do Drop Menu - aberto/fechado * @private * @param {object} element - referência ao Objeto que fará a ação */ _toggleDropMenu(element) { if (element.parentNode.classList.contains('active')) { // this.inSubmenu = false this._closeMenuElement(element) } else { element.parentNode.classList.add('active') element.setAttribute('aria-expanded', 'true') this.inSubmenu = true element.parentElement.querySelectorAll('ul li ul a').forEach((menuItem) => { this.triggerParent = menuItem.parentElement menuItem.addEventListener('keydown', (event) => { const { parentElement } = menuItem.parentElement const keyCode = event.code switch (keyCode) { case 'Escape': event.preventDefault() this._backMenu(parentElement) break case 'Backspace': event.preventDefault() this._backMenu(parentElement) break case 'ArrowLeft': event.preventDefault() this._backMenu(parentElement) break default: break } }) }) } } _backMenu(parentElement) { // parentElement.parentElement.querySelector('[data-click-listener]').click() } _closeMenuElement(element) { element.parentNode.classList.remove('active') element.setAttribute('aria-expanded', 'false') } /** * Gerencia eventos de cliques e interações por teclado no Side Menu - tecla espaço * @private * @param {object} element - referência ao Objeto que fará a ação */ _handleSideMenuInteraction(element) { // Verifica se o elemento já possui click listener através de um atributo especial if (!element.hasAttribute('data-click-listener')) { element.addEventListener('click', () => { this.inSubmenu = false this._toggleSideMenu(element) }) element.addEventListener('keydown', (event) => { const sideMenu = element.closest('.side-menu') const menuItem = sideMenu.querySelector('a.menu-item') if (sideMenu) { if (event.key === ' ' || event.key === 'Spacebar') { if (menuItem && menuItem.classList.contains('focus-visible')) { event.preventDefault() this._toggleSideMenu(element) } } } }) element.setAttribute('data-click-listener', 'true') } } /** * Muda estado do Side Menu - aberto/fechado * @private * @param {object} element - referência ao Objeto que fará a ação */ _toggleSideMenu(element) { this._hideItems(element) // Mostra itens do Side Menu ativo element.setAttribute('aria-expanded', 'true') this._showItems(element.parentNode) // Fecha Side Menu caso esteja aberto if (element.parentNode.classList.contains('active')) { this._closeSideMenu(element) element.focus() return } // Abre Side Menu element.parentNode.classList.add('active') // Foca no primeiro item do Side Menu const submenu = element.nextElementSibling if (submenu) { const firstMenuItem = submenu.querySelector('.menu-item') if (firstMenuItem) { firstMenuItem.focus() } } } /** * Fecha Side Menu * @private * @param {object} element - referência ao Objeto que fará a ação */ _closeSideMenu(element) { element.parentNode.classList.remove('active') element.setAttribute('aria-expanded', 'false') // Verifica se existe Side Menu anterior, caso contrário mostra todos os itens de volta const parentFolder = element.parentNode.closest('.side-menu.active') ? element.parentNode.closest('.side-menu.active') : element.closest('.menu-body') this._showItems(parentFolder) } /** * Esconde os elementos proximos a referencia * @private * @param {object} element - referencia ao Objeto que fará a ação */ _hideItems(element) { for (const item of element.closest('.menu-body').querySelectorAll('.menu-item')) { item.setAttribute('hidden', '') } } /** * Mostra os elementos proximos a referencia * @private * @param {object} element - referência ao Objeto que fará a ação */ _showItems(element) { for (const item of element.querySelectorAll('.menu-item')) { item.removeAttribute('hidden') } } /** * Cria icone filho a referencia * @private * @param {object} element - referência ao Objeto pai * @param {string} icon - nome da classe font awesome do ícone */ _createIcon(element, icon) { // Verifica se já existe container para o ícone if (!element.querySelectorAll('span.support').length) { const menuIconContainer = document.createElement('span') menuIconContainer.classList.add('support') const menuIcon = document.createElement('i') menuIcon.classList.add('fas') menuIcon.classList.add(icon) menuIcon.setAttribute('aria-hidden', 'true') menuIconContainer.appendChild(menuIcon) element.appendChild(menuIconContainer) } } /** * Adiciona atributos role=menu e role=menuitem com base na hierarquia dos elementos * @private */ _addARIAAttributes() { // Adiciona atributo role="menubar" à classe .menu-body const menuBody = this.component.querySelector('.menu-body') // menuBody.setAttribute('role', 'menubar') menuBody.setAttribute('role', 'tree') if (this.contextual) { menuBody.setAttribute('role', 'menubar') } // Adiciona atributo role="group" nos elementos .menu-item que são filhos de .menu-folder e não são drop-down const nonDropdownItems = this.component.querySelectorAll('.menu-folder:not(.drop-menu) > .menu-item') nonDropdownItems.forEach((item) => { item.setAttribute('role', 'tree') if (this.contextual) { item.setAttribute('role', 'menubar') } }) // Adiciona atributo role="menuitem" somente aos elementos <a> com a classe .menu-item que não têm .menu-folder como pai const menuItems = this.component.querySelectorAll('.menu-folder.drop-menu > a.menu-item, li > a.menu-item') menuItems.forEach((item) => { item.setAttribute('role', 'treeitem') if (this.contextual) { item.setAttribute('role', 'menuitem') } }) // Adiciona atributo role="menu" e aria-label nos elementos <ul> que são filhos de .side-menu const sideMenuLists = this.component.querySelectorAll('.side-menu > ul') sideMenuLists.forEach((list) => { const menuItem = list.parentNode.querySelector('.menu-item .content') const menuItemText = menuItem.textContent.trim() list.setAttribute('role', 'group') list.setAttribute('aria-label', menuItemText) }) // Adiciona atributo role="menu" e aria-label nos elementos <ul> que são filhos de .menu-folder const menuFolderLists = this.component.querySelectorAll('.menu-folder > ul') menuFolderLists.forEach((list) => { const menuItem = list.parentNode.querySelector('.menu-item .content') const menuItemText = menuItem.textContent.trim() list.setAttribute('role', 'tree') if (this.contextual) { list.setAttribute('role', 'menubar') } list.setAttribute('aria-label', menuItemText) }) const sideMenuItems = this.component.querySelectorAll('li.side-menu > .menu-item') for (const submenu of sideMenuItems) { submenu.setAttribute('aria-haspopup', 'true') submenu.setAttribute('aria-expanded', 'false') } const folderMenuItems = this.component.querySelectorAll('.menu-folder.drop-menu > .menu-item') for (const submenu of folderMenuItems) { submenu.setAttribute('aria-haspopup', 'true') submenu.setAttribute('aria-expanded', 'false') } } } export default BRMenu