From f3183936d81e2491fa2fbe555ecf5d341d033aa8 Mon Sep 17 00:00:00 2001 From: Orest Bida Date: Sat, 21 Oct 2023 17:00:00 +0200 Subject: [PATCH] Refactor: tweaked focus handling --- src/core/api.js | 18 +++++++++++++---- src/core/modals/consentModal.js | 16 ++++++--------- src/core/modals/preferencesModal.js | 12 +++++------ src/scss/core/_reset.scss | 2 ++ src/utils/general.js | 31 +++++++++++++++++++++++++---- tests/gui.test.js | 21 +++++++++++++++++++ 6 files changed, 76 insertions(+), 24 deletions(-) diff --git a/src/core/api.js b/src/core/api.js index 56fe6626..48e2e1e7 100644 --- a/src/core/api.js +++ b/src/core/api.js @@ -20,6 +20,7 @@ import { toggleDisableInteraction, fireEvent, getKeys, + focusAfterTransition, deepCopy } from '../utils/general'; @@ -197,13 +198,17 @@ export const show = (createModal) => { if(_state._disablePageInteraction) toggleDisableInteraction(true); + focusAfterTransition(_dom._cm, 1); + addClass(_dom._htmlDom, TOGGLE_CONSENT_MODAL_CLASS); setAttribute(_dom._cm, ARIA_HIDDEN, 'false'); /** * Set focus to consentModal */ - focus(_dom._cmContainer, 1); + setTimeout(() => { + focus(globalObj._dom._cmDivTabindex, 1); + }, 100); _log('CookieConsent [TOGGLE]: show consentModal'); @@ -257,8 +262,6 @@ export const showPreferences = () => { createPreferencesModal(miniAPI, createMainContainer); state._preferencesModalVisible = true; - addClass(globalObj._dom._htmlDom, TOGGLE_PREFERENCES_MODAL_CLASS); - setAttribute(globalObj._dom._pm, ARIA_HIDDEN, 'false'); // If there is no consent-modal, keep track of the last focused elem. if(!state._consentModalVisible){ @@ -267,10 +270,17 @@ export const showPreferences = () => { state._lastFocusedModalElement = getActiveElement(); } + focusAfterTransition(globalObj._dom._pm, 2); + + addClass(globalObj._dom._htmlDom, TOGGLE_PREFERENCES_MODAL_CLASS); + setAttribute(globalObj._dom._pm, ARIA_HIDDEN, 'false'); + /** * Set focus to preferencesModal */ - focus(globalObj._dom._pmContainer, 2); + setTimeout(() => { + focus(globalObj._dom._pmDivTabindex, 2); + }, 100); _log('CookieConsent [TOGGLE]: show preferencesModal'); diff --git a/src/core/modals/consentModal.js b/src/core/modals/consentModal.js index 062d975f..2fd8183d 100644 --- a/src/core/modals/consentModal.js +++ b/src/core/modals/consentModal.js @@ -93,7 +93,6 @@ export const createConsentModal = (api, createMainContainer) => { addClassCm(dom._cmTexts, 'texts'); addClassCm(dom._cmBtns, 'btns'); - dom._cmContainer.tabIndex = -1; setAttribute(dom._cm, 'role', 'dialog'); setAttribute(dom._cm, 'aria-modal', 'true'); setAttribute(dom._cm, ARIA_HIDDEN, 'false'); @@ -104,11 +103,6 @@ export const createConsentModal = (api, createMainContainer) => { else if(consentModalTitleValue) setAttribute(dom._cm, 'aria-labelledby', 'cm__title'); - /** - * Make modal by default hidden to prevent weird page jumps/flashes (shown only once css is loaded) - */ - dom._cm.style.visibility = 'hidden'; - const boxLayout = 'box', guiOptions = state._userConfig.guiOptions, @@ -140,6 +134,10 @@ export const createConsentModal = (api, createMainContainer) => { if(acceptAllBtnData || acceptNecessaryBtnData || showPreferencesBtnData) appendChild(dom._cmBody, dom._cmBtns); + dom._cmDivTabindex = createNode(DIV_TAG); + setAttribute(dom._cmDivTabindex, 'tabIndex', -1); + appendChild(dom._cm, dom._cmDivTabindex); + appendChild(dom._cm, dom._cmBody); appendChild(dom._cmContainer, dom._cm); } @@ -147,10 +145,8 @@ export const createConsentModal = (api, createMainContainer) => { if(consentModalTitleValue){ if(!dom._cmTitle){ - dom._cmTitle = createNode(DIV_TAG); + dom._cmTitle = createNode('h2'); dom._cmTitle.className = dom._cmTitle.id = 'cm__title'; - setAttribute(dom._cmTitle, 'role', 'heading'); - setAttribute(dom._cmTitle, 'aria-level', '2'); appendChild(dom._cmTexts, dom._cmTitle); } @@ -170,7 +166,7 @@ export const createConsentModal = (api, createMainContainer) => { } if(!dom._cmDescription){ - dom._cmDescription = createNode(DIV_TAG); + dom._cmDescription = createNode('p'); dom._cmDescription.className = dom._cmDescription.id = 'cm__desc'; appendChild(dom._cmTexts, dom._cmDescription); } diff --git a/src/core/modals/preferencesModal.js b/src/core/modals/preferencesModal.js index a1f0c2ed..90b39245 100644 --- a/src/core/modals/preferencesModal.js +++ b/src/core/modals/preferencesModal.js @@ -79,7 +79,6 @@ export const createPreferencesModal = (api, createMainContainer) => { // modal container dom._pmContainer = createNode(DIV_TAG); addClass(dom._pmContainer, 'pm-wrapper'); - dom._pmContainer.tabIndex = -1; // modal overlay const pmOverlay = createNode('div'); @@ -93,7 +92,6 @@ export const createPreferencesModal = (api, createMainContainer) => { // preferences modal dom._pm = createNode(DIV_TAG); - dom._pm.style.visibility = 'hidden'; addClass(dom._pm, 'pm'); setAttribute(dom._pm, 'role', 'dialog'); @@ -110,11 +108,9 @@ export const createPreferencesModal = (api, createMainContainer) => { dom._pmHeader = createNode(DIV_TAG); addClassPm(dom._pmHeader, 'header'); - dom._pmTitle = createNode(DIV_TAG); + dom._pmTitle = createNode('h2'); addClassPm(dom._pmTitle, 'title'); dom._pmTitle.id = 'pm__title'; - setAttribute(dom._pmTitle, 'role', 'heading'); - setAttribute(dom._pmTitle, 'aria-level', '2'); dom._pmCloseBtn = createNode(BUTTON_TAG); addClassPm(dom._pmCloseBtn, 'close-btn'); @@ -145,6 +141,10 @@ export const createPreferencesModal = (api, createMainContainer) => { appendChild(dom._pmHeader, dom._pmTitle); appendChild(dom._pmHeader, dom._pmCloseBtn); + dom._pmDivTabindex = createNode(DIV_TAG); + setAttribute(dom._pmDivTabindex, 'tabIndex', -1); + appendChild(dom._pm, dom._pmDivTabindex); + appendChild(dom._pm, dom._pmHeader); appendChild(dom._pm, dom._pmBody); @@ -305,7 +305,7 @@ export const createPreferencesModal = (api, createMainContainer) => { } if(sDescriptionData){ - var sDesc = createNode(DIV_TAG); + var sDesc = createNode('p'); addClassPm(sDesc, 'section-desc'); sDesc.innerHTML = sDescriptionData; diff --git a/src/scss/core/_reset.scss b/src/scss/core/_reset.scss index 2b54c633..c013e1ad 100644 --- a/src/scss/core/_reset.scss +++ b/src/scss/core/_reset.scss @@ -4,6 +4,8 @@ div, span, a, + h2, + p, button, input, ::before, diff --git a/src/utils/general.js b/src/utils/general.js index 0d083b65..e1a80932 100644 --- a/src/utils/general.js +++ b/src/utils/general.js @@ -517,7 +517,7 @@ export const uuidv4 = () => { * Add event listener to dom object (cross browser function) * @param {Element} elem * @param {keyof WindowEventMap} event - * @param {EventListenerOrEventListenerObject} fn + * @param {EventListener} fn * @param {boolean} [saveListener] */ export const addEvent = (elem, event, fn, saveListener) => { @@ -741,8 +741,8 @@ export const focus = (el, modalId, toggleTabIndex) => { if(modalId) { globalObj._state._currentFocusedModal = modalId === 1 - ? globalObj._dom._cmContainer - : globalObj._dom._pmContainer; + ? globalObj._dom._cm + : globalObj._dom._pm; globalObj._state._currentFocusEdges = modalId === 1 ? globalObj._state._cmFocusableElements @@ -756,6 +756,26 @@ export const focus = (el, modalId, toggleTabIndex) => { toggleTabIndex && (el && el.removeAttribute('tabindex')); }; +/** + * @param {HTMLDivElement} element + * @param {1 | 2} modalId + */ +export const focusAfterTransition = (element, modalId) => { + + const getVisibleDiv = (modalId) => modalId === 1 + ? globalObj._dom._cmDivTabindex + : globalObj._dom._pmDivTabindex; + + const setFocus = (event) => { + event.target.removeEventListener('transitionend', setFocus); + if (event.propertyName === 'opacity' && getComputedStyle(element).opacity === '1') { + focus(getVisibleDiv(modalId), modalId); + } + }; + + addEvent(element, 'transitionend', setFocus); +}; + /** * Obtain accepted and rejected categories * @returns {{accepted: string[], rejected: string[]}} @@ -818,8 +838,11 @@ export const handleFocusTrap = () => { const dom = globalObj._dom; const state = globalObj._state; + const trapFocusScope = globalObj._state._userConfig.disablePageInteraction + ? dom._htmlDom + : dom._ccMain; - addEvent(dom._htmlDom, 'keydown', (e) => { + addEvent(trapFocusScope, 'keydown', (e) => { if(e.key !== 'Tab') return; diff --git a/tests/gui.test.js b/tests/gui.test.js index 1805ef11..1a39f490 100644 --- a/tests/gui.test.js +++ b/tests/gui.test.js @@ -1,4 +1,5 @@ import { globalObj } from "../src/core/global"; +import { getActiveElement } from "../src/utils/general"; import * as CookieConsent from "../src/index" import testConfig from "./config/full-config"; @@ -131,6 +132,26 @@ describe("Test UI options", () =>{ expect(classList.contains('cm--box')).toBe(true); expect(classList2.contains('pm--box')).toBe(true); }); + + it('consentModal should receive focus when it is shown', async () => { + await api.run(testConfig); + const prevActiveElement = getActiveElement(); + api.show(); + await new Promise(r => setTimeout(r, 300)); + const currActiveElement = getActiveElement(); + expect(currActiveElement).not.toBe(prevActiveElement); + expect(document.querySelector('.cm > div[tabIndex="-1"]')).toBe(currActiveElement); + }); + + it('preferencesModal should receive focus when it is shown', async () => { + await api.run(testConfig); + const prevActiveElement = getActiveElement(); + api.showPreferences(); + await new Promise(r => setTimeout(r, 300)); + const currActiveElement = getActiveElement(); + expect(currActiveElement).not.toBe(prevActiveElement); + expect(document.querySelector('.pm > div[tabIndex="-1"]')).toBe(currActiveElement); + }); }) function getModalClassList(selector){