diff --git a/.eslintrc b/.eslintrc index 9ba35ca26f..c559b35178 100644 --- a/.eslintrc +++ b/.eslintrc @@ -10,7 +10,7 @@ "__MESSAGING_GLOBALS__": true, "__ENV__": true }, - "plugins": ["prettier"], + "plugins": ["prettier","eslint-comments"], "rules": { "arrow-body-style": "off", "unicorn/prefer-spread": "off", diff --git a/src/components/modal/lib/hooks/helpers.js b/src/components/modal/lib/hooks/helpers.js index 723e705f9f..6b615022e6 100644 --- a/src/components/modal/lib/hooks/helpers.js +++ b/src/components/modal/lib/hooks/helpers.js @@ -1,17 +1,5 @@ import { useEffect, useLayoutEffect, useRef } from 'preact/hooks'; -export function useAutoFocus() { - const ref = useRef(); - - useEffect(() => { - if (ref.current) { - ref.current.focus(); - } - }); - - return ref; -} - export function useDidUpdateEffect(fn, deps) { const mounted = useRef(false); diff --git a/src/components/modal/v2/lib/hooks/helpers.js b/src/components/modal/v2/lib/hooks/helpers.js index 723e705f9f..6b615022e6 100644 --- a/src/components/modal/v2/lib/hooks/helpers.js +++ b/src/components/modal/v2/lib/hooks/helpers.js @@ -1,17 +1,5 @@ import { useEffect, useLayoutEffect, useRef } from 'preact/hooks'; -export function useAutoFocus() { - const ref = useRef(); - - useEffect(() => { - if (ref.current) { - ref.current.focus(); - } - }); - - return ref; -} - export function useDidUpdateEffect(fn, deps) { const mounted = useRef(false); diff --git a/src/components/modal/v2/lib/providers/transition.js b/src/components/modal/v2/lib/providers/transition.js index 4b073e09f1..17c5d0d966 100644 --- a/src/components/modal/v2/lib/providers/transition.js +++ b/src/components/modal/v2/lib/providers/transition.js @@ -14,21 +14,21 @@ export const STATUS = { }; const TransitionContext = createContext({ - status: STATUS.OPEN, + status: STATUS.CLOSED, setStatus: () => {} }); export const TransitionStateProvider = ({ children }) => { const { onShow } = useXProps(); - const [state, setState] = useState(STATUS.OPEN); + const [state, setState] = useState(STATUS.CLOSED); /** * Set iniitial focus on modal open to the close button. * Particularly useful for those using screen readers and other accessibility functions. */ const focusCloseBtnOnModalOpen = () => { - const btn = document.querySelector('.close'); - btn?.focus(); + // focus the close button + document.querySelector('.close')?.focus(); }; useEffect(() => { diff --git a/src/components/modal/v2/parts/BodyContent.jsx b/src/components/modal/v2/parts/BodyContent.jsx index b2d0878194..6c5b4957fe 100644 --- a/src/components/modal/v2/parts/BodyContent.jsx +++ b/src/components/modal/v2/parts/BodyContent.jsx @@ -13,6 +13,15 @@ import { import Header from './Header'; import { LongTerm, ShortTerm, NoInterest, ProductList, PayIn1 } from './views'; +const VIEW_IDS = { + // TODO: add an error view in case we receive an invalid view? + PAYPAL_CREDIT_NO_INTEREST: 'PAYPAL_CREDIT_NO_INTEREST', + PAY_LATER_LONG_TERM: 'PAY_LATER_LONG_TERM', + PAY_LATER_PAY_IN_1: 'PAY_LATER_PAY_IN_1', + PAY_LATER_SHORT_TERM: 'PAY_LATER_SHORT_TERM', + PRODUCT_LIST: 'PRODUCT_LIST' +}; + const BodyContent = () => { const { views } = useServerData(); const { offer } = useXProps(); @@ -29,12 +38,12 @@ const BodyContent = () => { let defaultViewName; - const productViews = views.filter(view => view?.meta?.product !== 'PRODUCT_LIST'); - const hasProductList = views.find(view => view?.meta?.product === 'PRODUCT_LIST'); + const productViews = views.filter(view => view?.meta?.product !== VIEW_IDS.PRODUCT_LIST); + const hasProductList = views.find(view => view?.meta?.product === VIEW_IDS.PRODUCT_LIST); if (productViews?.length === 1) { defaultViewName = productViews[0]?.meta?.product; } else if (productViews?.length > 1 && hasProductList) { - defaultViewName = 'PRODUCT_LIST'; + defaultViewName = VIEW_IDS.PRODUCT_LIST; } else if (productViews?.length > 1 && !hasProductList) { defaultViewName = productViews[0]?.meta?.product; } @@ -49,12 +58,13 @@ const BodyContent = () => { const { headline, subheadline, qualifyingSubheadline = '', closeButtonLabel } = content; const isQualifying = productMeta?.qualifying; - const openProductList = () => setViewName('PRODUCT_LIST'); + const openProductList = () => setViewName(VIEW_IDS.PRODUCT_LIST); useDidUpdateEffect(() => { scrollTo(0); // Reset scroll position to top when view changes - const closeButton = window.document.querySelector('#close-btn'); - if (closeButton) closeButton.focus(); + if (transitionState === 'OPEN') { + window.document.querySelector('#close-btn')?.focus(); + } }, [viewName]); useDidUpdateEffect(() => { @@ -71,13 +81,13 @@ const BodyContent = () => { // Add views to viewComponents object where the keys are the product name and the values are the view component const viewComponents = { - PAYPAL_CREDIT_NO_INTEREST: , - PAY_LATER_LONG_TERM: , - PAY_LATER_PAY_IN_1: , - PAY_LATER_SHORT_TERM: ( + [VIEW_IDS.PAYPAL_CREDIT_NO_INTEREST]: , + [VIEW_IDS.PAY_LATER_LONG_TERM]: , + [VIEW_IDS.PAY_LATER_PAY_IN_1]: , + [VIEW_IDS.PAY_LATER_SHORT_TERM]: ( ), - PRODUCT_LIST: + [VIEW_IDS.PRODUCT_LIST]: }; // IMPORTANT: These elements cannot be nested inside of other elements. diff --git a/src/components/modal/v2/parts/Header.jsx b/src/components/modal/v2/parts/Header.jsx index 2f0ba9700f..e15c26f018 100644 --- a/src/components/modal/v2/parts/Header.jsx +++ b/src/components/modal/v2/parts/Header.jsx @@ -56,6 +56,7 @@ const Header = ({ aria-label={closeButtonLabel} type="button" id="close-btn" + aria-keyshortcuts="escape" onClick={() => handleClose('Close Button')} > diff --git a/src/components/modal/v2/styles/components/_header.scss b/src/components/modal/v2/styles/components/_header.scss index 31acbc2604..8d2a7aa20c 100644 --- a/src/components/modal/v2/styles/components/_header.scss +++ b/src/components/modal/v2/styles/components/_header.scss @@ -140,6 +140,10 @@ cursor: pointer; z-index: 9; + &:focus { + outline: -webkit-focus-ring-color solid 2px; + } + @include desktop { margin-left: auto; margin-right: 2px; diff --git a/src/library/zoid/message/component.js b/src/library/zoid/message/component.js index 626c09c1ba..cacec5cab4 100644 --- a/src/library/zoid/message/component.js +++ b/src/library/zoid/message/component.js @@ -134,17 +134,7 @@ export default createGlobalVariableGetter('__paypal_credit_message__', () => const { onClick } = props; return ({ meta }) => { - const { - modal, - index, - account, - merchantId, - currency, - amount, - buyerCountry, - onApply, - getContainer - } = props; + const { modal, index, account, merchantId, currency, amount, buyerCountry, onApply } = props; const { offerType, offerCountry, messageRequestId } = meta; // Avoid spreading message props because both message and modal @@ -160,10 +150,7 @@ export default createGlobalVariableGetter('__paypal_credit_message__', () => offerCountry, refId: messageRequestId, refIndex: index, - src: 'message_click', - onClose: () => { - getContainer().querySelector('iframe').focus(); - } + src: 'message_click' }); logger.track({ diff --git a/src/library/zoid/modal/containerTemplate.jsx b/src/library/zoid/modal/containerTemplate.jsx index 70961092a6..c8bd9829f5 100644 --- a/src/library/zoid/modal/containerTemplate.jsx +++ b/src/library/zoid/modal/containerTemplate.jsx @@ -30,6 +30,7 @@ export default ({ uid, frame, prerenderFrame, doc, event, state, props: { cspNon const handleShow = () => { state.open = true; + state.previousFocus = document.activeElement; wrapper.classList.remove(CLASS.HIDDEN); hijackViewport(); // Browser needs to repaint otherwise the transition happens immediately @@ -37,6 +38,11 @@ export default ({ uid, frame, prerenderFrame, doc, event, state, props: { cspNon requestAnimationFrame(() => { requestAnimationFrame(() => { overlay.classList.add(CLASS.MODAL_SHOW); + if (state.renderedModal && window.document.activeElement !== frame) { + frame.focus(); + } else if (window.document.activeElement !== prerenderFrame) { + prerenderFrame.focus(); + } }); }); }; @@ -47,6 +53,7 @@ export default ({ uid, frame, prerenderFrame, doc, event, state, props: { cspNon replaceViewport(); setTimeout(() => { wrapper.classList.add(CLASS.HIDDEN); + state.previousFocus.focus(); }, TRANSITION_DELAY); }; @@ -57,11 +64,18 @@ export default ({ uid, frame, prerenderFrame, doc, event, state, props: { cspNon }; const handleTransition = () => { + state.renderedModal = true; ZalgoPromise.delay(TRANSITION_DELAY) .then(() => overlay.classList.add(CLASS.TRANSITION)) .then(() => ZalgoPromise.delay(TRANSITION_DELAY)) - .then(() => destroyElement(prerenderFrame)); + .then(() => destroyElement(prerenderFrame)) + .then(() => { + if (state.open && document.activeElement !== frame) { + frame.focus(); + } + }); }; + // When the show function was called before zoid had a chance to render if (state.open) { handleShow(); diff --git a/src/library/zoid/modal/prerenderTemplate.jsx b/src/library/zoid/modal/prerenderTemplate.jsx index 50690e6dac..887045e7f2 100644 --- a/src/library/zoid/modal/prerenderTemplate.jsx +++ b/src/library/zoid/modal/prerenderTemplate.jsx @@ -1,15 +1,9 @@ -/* eslint-disable eslint-comments/disable-enable-pair */ -/* eslint-disable no-param-reassign */ -/* eslint-disable react/self-closing-comp */ -/* eslint-disable jsx-a11y/click-events-have-key-events */ -/* eslint-disable jsx-a11y/control-has-associated-label */ -/* eslint-disable jsx-a11y/no-static-element-interactions */ /** @jsx node */ import { node, dom } from '@krakenjs/jsx-pragmatic/src'; import { Spinner } from '@paypal/common-components'; import { ZalgoPromise } from '@krakenjs/zalgo-promise/src'; -export default ({ doc, props, event }) => { +export default ({ doc, props, event, state }) => { const ERROR_DELAY = 15000; const styles = ` @font-face { @@ -84,7 +78,6 @@ export default ({ doc, props, event }) => { position: relative !important; } .close-button > button { - background-image: url("data:image/svg+xml,%3Csvg width='48' height='48' viewBox='0 0 44 44' fill='transparent' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M12 0L0 12' transform='translate(12 12)' stroke='white' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M0 0L12 12' transform='translate(12 12)' stroke='white' stroke-width='2' stroke-linecap='round' /%3E%3C/svg%3E"); background-color: transparent; width: 48px; height: 48px; @@ -95,17 +88,24 @@ export default ({ doc, props, event }) => { position: absolute; z-index: 50; right: 0; + margin-right: 2px; + margin-top: 2px; } - .error{ + .close-button > button > svg { + width: 48px; + height: 48px; + } + + #modal-status{ color: white; - width: 200px; - height: 100px; position: absolute; top: 67%; - left: 50%; + left: calc( 50% - 10px ); margin-left: -60px; display: none; + padding: 10px; + } @media (max-width: 639px), (max-height: 539px){ @@ -119,20 +119,54 @@ export default ({ doc, props, event }) => { } `; + let closeBtn; - const closeModal = () => event.trigger('modal-hide'); const checkForErrors = element => { ZalgoPromise.delay(ERROR_DELAY).then(() => { - // check to see if modal content class exists - if (element.querySelector('.error')) { - // looks like there is an error if modal content class does not exist. + const modalStatus = element.querySelector('#modal-status'); + // if we have a place to put our status message, + // and we have not heard the 'zoid-rendered' event for the modal yet + if (modalStatus && !state.renderedModal) { // assign variable to state and access in UI - element.querySelector('.error').style.display = 'block'; - element.querySelector('.error').textContent = 'Error loading Modal'; + modalStatus.style.display = 'block'; + modalStatus.textContent = 'Error loading Modal'; + // TODO: should we report this failure to our log endpoint? } }); }; + const handleClose = () => { + event.trigger('modal-hide'); + }; + + const handleEscape = evt => { + if (!state.renderedModal && state.open && (evt.key === 'Escape' || evt.key === 'Esc' || evt.charCode === 27)) { + handleClose(); + } + }; + + const handleRender = element => { + closeBtn = element.querySelector('#prerender-close-btn'); + // we need to give chrome a moment before we can focus the close button + window.requestAnimationFrame(() => { + closeBtn?.focus(); + }); + ZalgoPromise.delay(ERROR_DELAY).then(() => { + return checkForErrors(element); + }); + }; + + event.on('modal-show', () => { + if (!state.renderedModal) { + // we need to give chrome a moment before we can focus the close button + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + closeBtn?.focus(); + }); + }); + } + }); + return ( @@ -140,15 +174,62 @@ export default ({ doc, props, event }) => { - -