Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Mini Cart: stop using Modal component #9345

Merged
merged 10 commits into from
May 16, 2023
139 changes: 116 additions & 23 deletions assets/js/base/components/drawer/index.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,35 @@
/**
* Some code of the Drawer component is based on the Modal component from Gutenberg:
* https://github.com/WordPress/gutenberg/blob/trunk/packages/components/src/modal/index.tsx
*/
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Modal } from 'wordpress-components';
import { useDebounce } from 'use-debounce';
import classNames from 'classnames';
import { useDebounce } from 'use-debounce';
import type { ForwardedRef, KeyboardEvent } from 'react';
import { __ } from '@wordpress/i18n';
import {
createPortal,
useEffect,
useRef,
forwardRef,
} from '@wordpress/element';
import { close } from '@wordpress/icons';
import {
useFocusReturn,
useFocusOnMount,
// eslint-disable-next-line @wordpress/no-unsafe-wp-apis
__experimentalUseFocusOutside as useFocusOutside,
useConstrainedTabbing,
useMergeRefs,
} from '@wordpress/compose';

/**
* Internal dependencies
*/
import Button from '../button';
import * as ariaHelper from './utils/aria-helper';
import './style.scss';

interface DrawerProps {
Expand All @@ -18,32 +39,77 @@ interface DrawerProps {
onClose: () => void;
slideIn?: boolean;
slideOut?: boolean;
title: string;
}

const Drawer = ( {
children,
className,
isOpen,
onClose,
slideIn = true,
slideOut = true,
title,
}: DrawerProps ): JSX.Element | null => {
const UnforwardedDrawer = (
{
children,
className,
isOpen,
onClose,
slideIn = true,
slideOut = true,
}: DrawerProps,
forwardedRef: ForwardedRef< HTMLDivElement >
): JSX.Element | null => {
const [ debouncedIsOpen ] = useDebounce< boolean >( isOpen, 300 );
const isClosing = ! isOpen && debouncedIsOpen;
const bodyOpenClassName = 'drawer-open';

const onRequestClose = () => {
document.body.classList.remove( bodyOpenClassName );
ariaHelper.showApp();
onClose();
};

const ref = useRef< HTMLDivElement >();
const focusOnMountRef = useFocusOnMount();
const constrainedTabbingRef = useConstrainedTabbing();
const focusReturnRef = useFocusReturn();
const focusOutsideProps = useFocusOutside( onRequestClose );
const contentRef = useRef< HTMLDivElement >( null );

useEffect( () => {
if ( isOpen ) {
ariaHelper.hideApp( ref.current );
document.body.classList.add( bodyOpenClassName );
}
}, [ isOpen, bodyOpenClassName ] );

const overlayRef = useMergeRefs( [ ref, forwardedRef ] );
const drawerRef = useMergeRefs( [
constrainedTabbingRef,
focusReturnRef,
focusOnMountRef,
] );

if ( ! isOpen && ! isClosing ) {
return null;
}

return (
<Modal
title={ title }
focusOnMount={ true }
onRequestClose={ onClose }
className={ classNames( className, 'wc-block-components-drawer' ) }
overlayClassName={ classNames(
function handleEscapeKeyDown( event: KeyboardEvent< HTMLDivElement > ) {
if (
// Ignore keydowns from IMEs
event.nativeEvent.isComposing ||
// Workaround for Mac Safari where the final Enter/Backspace of an IME composition
// is `isComposing=false`, even though it's technically still part of the composition.
// These can only be detected by keyCode.
event.keyCode === 229
) {
return;
}

if ( event.code === 'Escape' && ! event.defaultPrevented ) {
event.preventDefault();
onRequestClose();
}
}

return createPortal(
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
ref={ overlayRef }
className={ classNames(
'wc-block-components-drawer__screen-overlay',
{
'wc-block-components-drawer__screen-overlay--is-hidden':
Expand All @@ -54,11 +120,38 @@ const Drawer = ( {
slideOut,
}
) }
closeButtonLabel={ __( 'Close', 'woo-gutenberg-products-block' ) }
onKeyDown={ handleEscapeKeyDown }
>
{ children }
</Modal>
<div
className={ classNames(
className,
'wc-block-components-drawer'
) }
ref={ drawerRef }
role="dialog"
tabIndex={ -1 }
{ ...focusOutsideProps }
>
<div
className="wc-block-components-drawer__content"
role="document"
ref={ contentRef }
>
<Button
className="wc-block-components-drawer__close"
onClick={ onRequestClose }
icon={ close }
label={ __( 'Close', 'woo-gutenberg-products-block' ) }
showTooltip={ false }
/>
{ children }
</div>
</div>
</div>,
document.body
);
};

export const Drawer = forwardRef( UnforwardedDrawer );

export default Drawer;
81 changes: 47 additions & 34 deletions assets/js/base/components/drawer/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -111,44 +111,57 @@ $drawer-animation-duration: 0.3s;
}
}

.wc-block-components-drawer .components-modal__content {
padding: $gap-largest $gap;
// Important rules are needed to reset button styles.
.wc-block-components-drawer__close {
@include reset-box();
background: transparent !important;
color: inherit !important;
position: absolute !important;
top: $gap-small;
right: $gap-small;
opacity: 0.6;
z-index: 2;
// Increase clickable area.
padding: 1em !important;
margin: -1em;

&:hover,
&:focus,
&:active {
opacity: 1;
}

// Don't show focus styles if the close button hasn't been focused by the
// user directly. This is done to prevent focus styles to appear when
// opening the drawer with the mouse, as the focus is moved inside
// programmatically.
&:focus:not(:focus-visible) {
box-shadow: none;
outline: none;
}

> span {
@include visually-hidden();
}
svg {
fill: currentColor;
display: block;
}
}

.wc-block-components-drawer .components-modal__header {
.wc-block-components-drawer__content {
height: 100dvh;
position: relative;
}

// Close button.
.components-button {
@include reset-box();
background: transparent;
color: inherit;
position: absolute;
opacity: 0.6;
z-index: 2;
// The SVG has some white spacing around, thus we have to set this magic number.
right: 8px;
top: 0;
// Increase clickable area.
padding: 1em;
margin: -1em;

&:hover,
&:focus,
&:active {
opacity: 1;
}

> span {
@include visually-hidden();
}
}
.admin-bar .wc-block-components-drawer__content {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This has been moved from here:

.admin-bar .wp-block-woocommerce-mini-cart-contents {
margin-top: 46px;
height: calc(100dvh - 46px);
}
@media only screen and (min-width: 783px) {
.admin-bar .wp-block-woocommerce-mini-cart-contents {
margin-top: 32px;
height: calc(100dvh - 32px);
}
}

margin-top: 46px;
height: calc(100dvh - 46px);
}

// Same styles as `Title` component.
.wc-block-components-drawer .components-modal__header-heading {
@include reset-box();
// We need the font size to be in rem so it doesn't change depending on the parent element.
@include font-size(large, 1rem);
word-break: break-word;
@media only screen and (min-width: 783px) {
.admin-bar .wc-block-components-drawer__content {
margin-top: 32px;
height: calc(100dvh - 32px);
}
}
74 changes: 74 additions & 0 deletions assets/js/base/components/drawer/utils/aria-helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Copied from https://github.com/WordPress/gutenberg/blob/trunk/packages/components/src/modal/aria-helper.ts
*/
const LIVE_REGION_ARIA_ROLES = new Set( [
'alert',
'status',
'log',
'marquee',
'timer',
] );

let hiddenElements: Element[] = [],
isHidden = false;

/**
* Determines if the passed element should not be hidden from screen readers.
*
* @param {HTMLElement} element The element that should be checked.
*
* @return {boolean} Whether the element should not be hidden from screen-readers.
*/
export function elementShouldBeHidden( element: Element ) {
const role = element.getAttribute( 'role' );
return ! (
element.tagName === 'SCRIPT' ||
element.hasAttribute( 'aria-hidden' ) ||
element.hasAttribute( 'aria-live' ) ||
( role && LIVE_REGION_ARIA_ROLES.has( role ) )
);
}

/**
* Hides all elements in the body element from screen-readers except
* the provided element and elements that should not be hidden from
* screen-readers.
*
* The reason we do this is because `aria-modal="true"` currently is bugged
* in Safari, and support is spotty in other browsers overall. In the future
* we should consider removing these helper functions in favor of
* `aria-modal="true"`.
*
* @param {HTMLDivElement} unhiddenElement The element that should not be hidden.
*/
export function hideApp( unhiddenElement?: HTMLDivElement ) {
if ( isHidden ) {
return;
}
const elements = Array.from( document.body.children );
elements.forEach( ( element ) => {
if ( element === unhiddenElement ) {
return;
}
if ( elementShouldBeHidden( element ) ) {
element.setAttribute( 'aria-hidden', 'true' );
hiddenElements.push( element );
}
} );
isHidden = true;
}

/**
* Makes all elements in the body that have been hidden by `hideApp`
* visible again to screen-readers.
*/
export function showApp() {
if ( ! isHidden ) {
return;
}
hiddenElements.forEach( ( element ) => {
element.removeAttribute( 'aria-hidden' );
} );
hiddenElements = [];
isHidden = false;
}
Loading