Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: safari focus #997

Merged
merged 13 commits into from
Oct 17, 2023
4 changes: 3 additions & 1 deletion src/components/modal/content/US/parts/Content.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,9 @@ const Content = ({ headerRef, contentWrapper }) => {
<div>Subject to credit approval.</div>
<hr className="divider" />
</div>
<main className="main">{tabsContent}</main>
<main className="main" aria-label={`More info on the Pay Later offer${tabs.length > 1 ? 's' : ''}.`}>
{tabsContent}
</main>
</div>
);
};
Expand Down
12 changes: 0 additions & 12 deletions src/components/modal/lib/hooks/helpers.js
Original file line number Diff line number Diff line change
@@ -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);

Expand Down
13 changes: 9 additions & 4 deletions src/components/modal/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,18 @@ export function setupTabTrap() {
const tabArray = arrayFrom(document.querySelectorAll(focusableElementsString)).filter(
node => window.getComputedStyle(node).visibility === 'visible'
);
let nextElement;
// SHIFT + TAB
if (e.shiftKey && document.activeElement === tabArray[0]) {
nextElement = tabArray[tabArray.length - 1];
} else if (document.activeElement === tabArray[tabArray.length - 1]) {
// eslint-disable-next-line prefer-destructuring
nextElement = tabArray[0];
}

if (typeof nextElement !== 'undefined') {
e.preventDefault();
tabArray[tabArray.length - 1].focus();
} else if (!e.shiftKey && document.activeElement === tabArray[tabArray.length - 1]) {
e.preventDefault();
tabArray[0].focus();
nextElement.focus();
}
}
}
Expand Down
12 changes: 0 additions & 12 deletions src/components/modal/v2/lib/hooks/helpers.js
Original file line number Diff line number Diff line change
@@ -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);

Expand Down
6 changes: 4 additions & 2 deletions src/components/modal/v2/lib/providers/transition.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,10 @@ export const TransitionStateProvider = ({ children }) => {
* Particularly useful for those using screen readers and other accessibility functions.
*/
const focusCloseBtnOnModalOpen = () => {
const btn = document.querySelector('.close');
btn?.focus();
// give the document time to update before trying to focus the close button
setTimeout(() => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there an event we should be looking for like the document DOMContentLoaded or window load event? If so would we be able to leverage these either of these helpers? https://github.com/paypal/paypal-messaging-components/blob/develop/src/utils/events.js#L11-L21. If we aren't waiting for a specific DOM event and just need to ensure the browser has had a render cycle, could we use requestAnimationFrame instead? The primary concern with setTimeout is it is an arbitrary time that may or may not fire the same based on device performance.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While manually testing in Safari, I found that

  1. The the close button for the modal needed the autofocus attribute
  2. The iframe for the modal in the parent page needed to receive focus
  3. The close button for the modal needed to receive focus

The events appeared to be happening in the correct order, but from what I could tell, the iframe was going to be given focus, but had not yet received focus (I found something similar happening with the tabTrap).

I thought about setting up listeners to ensure the button focused happened after the iframe, but I realized I would need

  1. on the parent page, I would need to listen for when the iframe received focus, then pass the info onto
  2. the zoid event emitter in the parent page, the pass the info onto
  3. a listener in the child iframe, that would finally add focus to the button

I thought about it some more, and I figured the direct pipeline I would setup would be unideal. We are already using MODAL_EVENT.MODAL_SHOW to communicate almost the same thing. Creating a pipelline for MODAL_EVENT.FOCUS seemed like a lot of extra code to add onto messaging just to focus the close button.

I found that bumping it to a different place in the event cycle was enough for it to behave correctly though, not necessarily needing to wait literally 10ms.

I'll change it to requestAnimationFrame since it appears to work too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After further testing, I've realized my PR only partially fixed the issue:

  • modal open
  • modal close
  • message has focus
  • modal open
  • modal close
  • modal still has focus

Going to keep working on it

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've gotten it to a decent state, I think. The two caveats I'm thinking of are:

  1. We'll need to revisit making sure the prerender modal is accessible. I tried to clean it up a bit, but
    1. I couldn't get its close button to receive focus after opening, closing, and opening again.
    2. I wasn't at all certain how to effectively communicate via accessibility tags to a non-visual user "you are on a modal that is currently loading content" or announce "the modal has failed to load"
  2. The modal button receives focus in Chrome, but only the modal receives focus in Safari. Apparently, Apple intends this as a "feature"; the user must directly interact with the iframe before any elements in the iframe can be given focus. After the user interacts with the elements within the iframe for the modal, then we can call focus() on the close button. Since the modal can still be navigated after the iframe for the modal receives focus, I was going to let this one go

document.querySelector('.close')?.focus();
}, 10);
};

useEffect(() => {
Expand Down
10 changes: 7 additions & 3 deletions src/components/modal/v2/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,17 @@ export function setupTabTrap() {
const tabArray = arrayFrom(document.querySelectorAll(focusableElementsString)).filter(
node => window.getComputedStyle(node).visibility === 'visible'
);
let nextElement;
// SHIFT + TAB
if (e.shiftKey && document.activeElement === tabArray[0]) {
e.preventDefault();
tabArray[tabArray.length - 1].focus();
nextElement = tabArray[tabArray.length - 1];
} else if (document.activeElement === tabArray[tabArray.length - 1]) {
// eslint-disable-next-line prefer-destructuring
nextElement = tabArray[0];
}
if (typeof nextElement !== 'undefined') {
e.preventDefault();
tabArray[0].focus();
nextElement.focus();
}
}
}
Expand Down
32 changes: 22 additions & 10 deletions src/components/modal/v2/parts/BodyContent.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
};
Seavenly marked this conversation as resolved.
Show resolved Hide resolved

const BodyContent = () => {
const { views } = useServerData();
const { offer } = useXProps();
Expand All @@ -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;
}
Expand All @@ -49,7 +58,7 @@ 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
Expand All @@ -71,13 +80,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: <NoInterest content={content} openProductList={openProductList} />,
PAY_LATER_LONG_TERM: <LongTerm content={content} openProductList={openProductList} />,
PAY_LATER_PAY_IN_1: <PayIn1 content={content} openProductList={openProductList} />,
PAY_LATER_SHORT_TERM: (
[VIEW_IDS.PAYPAL_CREDIT_NO_INTEREST]: <NoInterest content={content} openProductList={openProductList} />,
[VIEW_IDS.PAY_LATER_LONG_TERM]: <LongTerm content={content} openProductList={openProductList} />,
[VIEW_IDS.PAY_LATER_PAY_IN_1]: <PayIn1 content={content} openProductList={openProductList} />,
[VIEW_IDS.PAY_LATER_SHORT_TERM]: (
<ShortTerm content={content} productMeta={productMeta} openProductList={openProductList} />
),
PRODUCT_LIST: <ProductList content={content} setViewName={setViewName} />
[VIEW_IDS.PRODUCT_LIST]: <ProductList content={content} setViewName={setViewName} />
};

// IMPORTANT: These elements cannot be nested inside of other elements.
Expand All @@ -95,7 +104,10 @@ const BodyContent = () => {
viewName={viewName}
/>
<div className="content__container">
<main className="main">
<main
className="main"
aria-label={`More info on the Pay Later offer${viewName === VIEW_IDS.PRODUCT_LIST ? 's' : ''}.`}
Seavenly marked this conversation as resolved.
Show resolved Hide resolved
>
<div className="content__body">{viewComponents[viewName]}</div>
</main>
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/components/modal/v2/parts/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ const Header = ({
aria-label={closeButtonLabel}
type="button"
id="close-btn"
autofocus
aria-keyshortcuts="escape"
onClick={() => handleClose('Close Button')}
>
<Icon name="close" />
Expand Down
5 changes: 4 additions & 1 deletion src/library/zoid/modal/containerTemplate.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export default ({ uid, frame, prerenderFrame, doc, event, state, props: { cspNon
replaceViewport();
setTimeout(() => {
wrapper.classList.add(CLASS.HIDDEN);
wrapper.querySelector('iframe')?.blur();
Seavenly marked this conversation as resolved.
Show resolved Hide resolved
}, TRANSITION_DELAY);
};

Expand All @@ -60,8 +61,10 @@ export default ({ uid, frame, prerenderFrame, doc, event, state, props: { cspNon
ZalgoPromise.delay(TRANSITION_DELAY)
.then(() => overlay.classList.add(CLASS.TRANSITION))
.then(() => ZalgoPromise.delay(TRANSITION_DELAY))
.then(() => destroyElement(prerenderFrame));
.then(() => destroyElement(prerenderFrame))
.then(() => wrapper.querySelector('iframe')?.focus());
Seavenly marked this conversation as resolved.
Show resolved Hide resolved
};

// When the show function was called before zoid had a chance to render
if (state.open) {
handleShow();
Expand Down
68 changes: 53 additions & 15 deletions src/library/zoid/modal/prerenderTemplate.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,3 @@
/* 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';
Expand Down Expand Up @@ -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;
Expand All @@ -95,6 +88,13 @@ export default ({ doc, props, event }) => {
position: absolute;
z-index: 50;
right: 0;
margin-right: 2px;
margin-top: 2px;
}

.close-button > button > svg {
width: 48px;
height: 48px;
}

.error{
Expand Down Expand Up @@ -123,16 +123,23 @@ export default ({ doc, props, event }) => {
const closeModal = () => event.trigger('modal-hide');
const checkForErrors = element => {
ZalgoPromise.delay(ERROR_DELAY).then(() => {
const errorElement = element.querySelector('#errMsg');
// check to see if modal content class exists
if (element.querySelector('.error')) {
if (errorElement) {
// looks like there is an error if modal content class does not exist.
// assign variable to state and access in UI
element.querySelector('.error').style.display = 'block';
element.querySelector('.error').textContent = 'Error loading Modal';
errorElement.style.display = 'block';
errorElement.textContent = 'Error loading Modal';
// TODO: should we report this failure to our log endpoint?
}
});
};

const focusCloseButton = element => {
window.requestAnimationFrame(() => {
// TODO: determine how to get this to re-focus if the prerender is dismissed and re-opened
element.focus();
});
};
return (
<html lang="en">
<head>
Expand All @@ -141,14 +148,45 @@ export default ({ doc, props, event }) => {
</head>
<style nonce={props.cspNonce}>{styles}</style>
<body onRender={checkForErrors}>
<div class="modal">
<div class="overlay" onClick={closeModal} />
<div class="modal" aria-errormessage="errMsg">
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */}
<div class="overlay" role="dialog" onClick={closeModal} />
<div class="top-overlay" />
<div class="modal-content">
<div class="close-button">
<button onClick={closeModal} type="button" />
<button
id="prerender-close-btn"
onClick={closeModal}
type="button"
aria-label="Close"
onRender={focusCloseButton}
>
<svg
aria-hidden="true"
width="36"
height="36"
viewBox="0 0 36 36"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 0L0 12"
transform="translate(12 12)"
stroke="white"
stroke-width="2"
stroke-linecap="round"
/>
<path
d="M0 0L12 12"
transform="translate(12 12)"
stroke="white"
stroke-width="2"
stroke-linecap="round"
/>
</svg>
</button>
</div>
<div class="error"></div>
<div id="errMsg" class="error" />
<Spinner nonce={props.cspNonce} />
</div>
</div>
Expand Down
19 changes: 1 addition & 18 deletions tests/unit/spec/src/components/lib/hooks/helpers.test.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
Loading