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 }) => {
-
-
-
+ {/*
+ disable jsx-a11y/no-static-element-interactions
+ because we need handleEscape to work regardless of which element has focus,
+ and Safari currently forbids an iframe from setting focus within its document
+ until the user interacts with the contents of the iframe
+ */}
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
+
+
+ {/*
+ disable jsx-a11y/click-events-have-key-events
+ because although the overlay does not have a keyup listener, the body does
+ disable jsx-a11y/no-static-element-interactions
+ because if we give it `role="button"`, then it will require the overlay be
+ focusable, which is unnecessary given the `#prerender-close-btn`
+ */}
+ {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */}
+
diff --git a/tests/unit/spec/src/components/lib/hooks/helpers.test.js b/tests/unit/spec/src/components/lib/hooks/helpers.test.js
index 3051c1a2b3..24e25a1cc9 100644
--- a/tests/unit/spec/src/components/lib/hooks/helpers.test.js
+++ b/tests/unit/spec/src/components/lib/hooks/helpers.test.js
@@ -1,24 +1,7 @@
import { renderHook } from '@testing-library/preact-hooks';
-import { useAutoFocus, useDidUpdateEffect } from 'src/components/modal/lib/hooks/helpers';
+import { useDidUpdateEffect } from 'src/components/modal/lib/hooks/helpers';
describe('hooks helpers', () => {
- describe('useAutoFocus', () => {
- test('Auto focuses the returned ref', () => {
- const button = document.createElement('button');
- button.focus = jest.fn();
- const { rerender } = renderHook(() => {
- const focustRef = useAutoFocus();
- focustRef.current = button;
- });
-
- expect(button.focus).toHaveBeenCalledTimes(1);
-
- rerender();
-
- expect(button.focus).toHaveBeenCalledTimes(2);
- });
- });
-
describe('useDidUpdateEffect', () => {
test('Runs effect function only when dependencies update', () => {
const effectFn = jest.fn();