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
2 changes: 1 addition & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"__MESSAGING_GLOBALS__": true,
"__ENV__": true
},
"plugins": ["prettier"],
"plugins": ["prettier","eslint-comments"],
"rules": {
"arrow-body-style": "off",
"unicorn/prefer-spread": "off",
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
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
17 changes: 12 additions & 5 deletions src/components/modal/v2/lib/providers/transition.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,23 @@ 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();
// give the document time to update before trying to focus the close button
window.requestAnimationFrame(() => {
document.querySelector('.close')?.focus();
});
};

useEffect(() => {
Expand All @@ -41,6 +43,7 @@ export const TransitionStateProvider = ({ children }) => {
if (entry.isIntersecting) {
// Removes .modal-closed class from modal iframe body when modal is open.
document.body.classList.remove('modal-closed');
document.body.classList.add('modal-open');
Seavenly marked this conversation as resolved.
Show resolved Hide resolved
setState(STATUS.OPEN);
onShow();

Expand All @@ -52,7 +55,10 @@ export const TransitionStateProvider = ({ children }) => {
* The .modal-closed class is added via useTransitionState. If this class is not on the modal iframe body,
* we know the modal is open and should not trigger the hook to reset the modal to the primary view.
*/
if (document.body.classList.contains('modal-closed')) {
if (
document.body.classList.contains('modal-closed') ||
!document.body.classList.contains('modal-open')
) {
setState(STATUS.CLOSED);
}
}, TRANSITION_DELAY);
Expand Down Expand Up @@ -85,6 +91,7 @@ export const useTransitionState = () => {
linkName => {
// Appends a class to the modal iframe body when handleClose is fired.
document.body.classList.add('modal-closed');
document.body.classList.remove('modal-open');
onClose({ linkName });

if (window === window.top && typeof close === 'function') {
Expand Down
32 changes: 21 additions & 11 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,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(() => {
Expand All @@ -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: <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 Down
1 change: 1 addition & 0 deletions src/components/modal/v2/parts/Header.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const Header = ({
aria-label={closeButtonLabel}
type="button"
id="close-btn"
aria-keyshortcuts="escape"
onClick={() => handleClose('Close Button')}
>
<Icon name="close" />
Expand Down
4 changes: 4 additions & 0 deletions src/components/modal/v2/styles/components/_header.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/library/zoid/message/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,9 @@ export default createGlobalVariableGetter('__paypal_credit_message__', () =>
refIndex: index,
src: 'message_click',
onClose: () => {
getContainer().querySelector('iframe').focus();
window.requestAnimationFrame(() => {
Seavenly marked this conversation as resolved.
Show resolved Hide resolved
getContainer().querySelector('iframe').focus();
});
}
});

Expand Down
30 changes: 27 additions & 3 deletions src/library/zoid/modal/containerTemplate.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export default ({ uid, frame, prerenderFrame, doc, event, state, props: { cspNon
// In this scenario we can skip creating container elements and transitions since we
// cannot overlay across the entire screen
if (context === 'popup') return undefined;

let renderedModal = false;
let previousFocus = document.activeElement;
Seavenly marked this conversation as resolved.
Show resolved Hide resolved
const [hijackViewport, replaceViewport] = viewportHijack();

const CLASS = {
Expand All @@ -30,13 +31,21 @@ export default ({ uid, frame, prerenderFrame, doc, event, state, props: { cspNon

const handleShow = () => {
state.open = true;
previousFocus = document.activeElement;
wrapper.classList.remove(CLASS.HIDDEN);
hijackViewport();
// Browser needs to repaint otherwise the transition happens immediately
// Firefox requires 2 RAFs due to where they are called in the event loop
requestAnimationFrame(() => {
requestAnimationFrame(() => {
overlay.classList.add(CLASS.MODAL_SHOW);
requestAnimationFrame(() => {
Seavenly marked this conversation as resolved.
Show resolved Hide resolved
if (renderedModal) {
frame.focus();
} else if (window.document.activeElement !== prerenderFrame) {
Seavenly marked this conversation as resolved.
Show resolved Hide resolved
prerenderFrame.focus();
}
});
});
});
};
Expand All @@ -47,21 +56,36 @@ export default ({ uid, frame, prerenderFrame, doc, event, state, props: { cspNon
replaceViewport();
setTimeout(() => {
wrapper.classList.add(CLASS.HIDDEN);

requestAnimationFrame(() => {
Seavenly marked this conversation as resolved.
Show resolved Hide resolved
if (renderedModal) {
frame.blur();
} else {
previousFocus.focus();
}
Seavenly marked this conversation as resolved.
Show resolved Hide resolved
});
}, TRANSITION_DELAY);
};

const handleEscape = evt => {
if (state.open && (evt.key === 'Escape' || evt.key === 'Esc' || evt.charCode === 27)) {
if (state.open && (`${evt?.key}`.toLowerCase().startsWith('esc') || evt.charCode === 27)) {
Seavenly marked this conversation as resolved.
Show resolved Hide resolved
handleHide();
}
};

const handleTransition = () => {
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) {
Seavenly marked this conversation as resolved.
Show resolved Hide resolved
frame.focus();
}
});
};

// When the show function was called before zoid had a chance to render
if (state.open) {
handleShow();
Expand Down
Loading
Loading