diff --git a/playroom/snippets.tsx b/playroom/snippets.tsx index 5acaae3e6..ce20a1ffe 100644 --- a/playroom/snippets.tsx +++ b/playroom/snippets.tsx @@ -62,6 +62,35 @@ const menuSnippet = { group: 'Menu', }; +const drawerSnippet = { + name: 'Drawer', + group: 'Drawer', + code: ` + setState("openDrawer", true)}> + Open Drawer + + {getState("openDrawer", false) && ( + setState("openDrawer", false)} + button={{ text: "Primary", onPress: () => {} }} + secondaryButton={{ text: "Secondary", onPress: () => {} }} + buttonLink={{ text: "Link", onPress: () => {} }} + onDismiss={() => {}} + > + + + + + + + + )} + `, +}; + const accordionSnippets: Array = [ { group: 'Accordion', @@ -4337,4 +4366,5 @@ export default [ ...ProgressBlockSnippets, ...loadingScreenSnippets, ...tableSnippets, + drawerSnippet, ].sort((s1, s2) => s1.group.localeCompare(s2.group)) as Array; diff --git a/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-desktop-actions-false-dismissible-true-content-length-1-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-desktop-actions-false-dismissible-true-content-length-1-1-snap.png new file mode 100644 index 000000000..1c2424096 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-desktop-actions-false-dismissible-true-content-length-1-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-desktop-actions-true-dismissible-true-content-length-1-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-desktop-actions-true-dismissible-true-content-length-1-1-snap.png new file mode 100644 index 000000000..668429ca8 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-desktop-actions-true-dismissible-true-content-length-1-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-mobile-ios-actions-false-dismissible-true-content-length-1-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-mobile-ios-actions-false-dismissible-true-content-length-1-1-snap.png new file mode 100644 index 000000000..cb2653a76 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-mobile-ios-actions-false-dismissible-true-content-length-1-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-mobile-ios-actions-true-dismissible-false-content-length-1-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-mobile-ios-actions-true-dismissible-false-content-length-1-1-snap.png new file mode 100644 index 000000000..cc6b13613 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-mobile-ios-actions-true-dismissible-false-content-length-1-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-mobile-ios-actions-true-dismissible-true-content-length-1-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-mobile-ios-actions-true-dismissible-true-content-length-1-1-snap.png new file mode 100644 index 000000000..953302a6d Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-mobile-ios-actions-true-dismissible-true-content-length-1-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-mobile-ios-actions-true-dismissible-true-content-length-5-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-mobile-ios-actions-true-dismissible-true-content-length-5-1-snap.png new file mode 100644 index 000000000..e7184ed55 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-mobile-ios-actions-true-dismissible-true-content-length-5-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-tablet-actions-false-dismissible-true-content-length-1-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-tablet-actions-false-dismissible-true-content-length-1-1-snap.png new file mode 100644 index 000000000..4ffc6b414 Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-tablet-actions-false-dismissible-true-content-length-1-1-snap.png differ diff --git a/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-tablet-actions-true-dismissible-true-content-length-1-1-snap.png b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-tablet-actions-true-dismissible-true-content-length-1-1-snap.png new file mode 100644 index 000000000..1cf1c13bd Binary files /dev/null and b/src/__screenshot_tests__/__image_snapshots__/drawer-screenshot-test-tsx-drawer-tablet-actions-true-dismissible-true-content-length-1-1-snap.png differ diff --git a/src/__screenshot_tests__/drawer-screenshot-test.tsx b/src/__screenshot_tests__/drawer-screenshot-test.tsx new file mode 100644 index 000000000..cbdbc6621 --- /dev/null +++ b/src/__screenshot_tests__/drawer-screenshot-test.tsx @@ -0,0 +1,36 @@ +import {openStoryPage, screen} from '../test-utils'; + +import type {Device} from '../test-utils'; + +test.each` + device | withActions | dismissible | contentLength + ${'MOBILE_IOS'} | ${true} | ${true} | ${1} + ${'MOBILE_IOS'} | ${true} | ${true} | ${5} + ${'MOBILE_IOS'} | ${true} | ${false} | ${1} + ${'DESKTOP'} | ${true} | ${true} | ${1} + ${'TABLET'} | ${true} | ${true} | ${1} + ${'MOBILE_IOS'} | ${false} | ${true} | ${1} + ${'DESKTOP'} | ${false} | ${true} | ${1} + ${'TABLET'} | ${false} | ${true} | ${1} +`( + 'Drawer $device actions:$withActions dismissible:$dismissible contentLength:$contentLength', + async ({device, withActions, dismissible, contentLength}) => { + const page = await openStoryPage({ + id: 'components-modals-drawer--default', + device: device as Device, + args: { + showButton: withActions, + showSecondaryButton: withActions, + showButtonLink: withActions, + onDismissHandler: dismissible, + contentLength, + }, + }); + + const button = await screen.findByRole('button', {name: 'Open Drawer'}); + await button.click(); + + const image = await page.screenshot(); + expect(image).toMatchImageSnapshot(); + } +); diff --git a/src/__stories__/drawer-story.tsx b/src/__stories__/drawer-story.tsx new file mode 100644 index 000000000..b823a6253 --- /dev/null +++ b/src/__stories__/drawer-story.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import Drawer from '../drawer'; +import {Placeholder} from '../placeholder'; +import Stack from '../stack'; +import {ButtonPrimary} from '../button'; +import {Text3} from '../text'; + +export default { + title: 'Components/Modals/Drawer', + component: Drawer, + argTypes: { + width: { + control: {type: 'range', min: 300, max: 1000, step: 1}, + }, + }, +}; + +type Args = { + title: string; + subtitle: string; + description: string; + contentLength: number; + onDismissHandler: boolean; + showButton: boolean; + showSecondaryButton: boolean; + showButtonLink: boolean; + width: number; +}; + +export const Default = ({ + title, + subtitle, + description, + contentLength, + onDismissHandler, + showButton, + showSecondaryButton, + showButtonLink, + width, +}: Args): JSX.Element => { + const [isOpen, setIsOpen] = React.useState(false); + const [result, setResult] = React.useState(''); + const content = ( + + {Array.from({length: contentLength}).map((_, index) => ( + + ))} + + ); + + return ( + <> + + setIsOpen(true)}>Open Drawer + + Result: {result} + + + {isOpen && ( + setIsOpen(false)} + onDismiss={onDismissHandler ? () => setResult('dismiss') : undefined} + button={showButton ? {text: 'Primary', onPress: () => setResult('primary')} : undefined} + secondaryButton={ + showSecondaryButton + ? {text: 'Secondary', onPress: () => setResult('secondary')} + : undefined + } + buttonLink={showButtonLink ? {text: 'Link', onPress: () => setResult('link')} : undefined} + > + {content} + + )} + + ); +}; + +Default.storyName = 'Drawer'; + +Default.args = { + title: 'Title', + subtitle: 'Subtitle', + description: 'Description', + contentLength: 2, + onDismissHandler: true, + showButton: true, + showSecondaryButton: true, + showButtonLink: true, + width: 0, +}; diff --git a/src/__tests__/drawer-test.tsx b/src/__tests__/drawer-test.tsx new file mode 100644 index 000000000..f6dca5f14 --- /dev/null +++ b/src/__tests__/drawer-test.tsx @@ -0,0 +1,115 @@ +import * as React from 'react'; +import {makeTheme} from './test-utils'; +import {render, screen, waitFor} from '@testing-library/react'; +import ThemeContextProvider from '../theme-context-provider'; +import Drawer from '../drawer'; +import userEvent from '@testing-library/user-event'; + +const DrawerTest = ({ + onDismiss, + onButtonPrimaryPress, + onButtonSecondaryPress, + onButtonLinkPress, +}: { + onDismiss?: () => void; + onButtonPrimaryPress?: () => void; + onButtonSecondaryPress?: () => void; + onButtonLinkPress?: () => void; +}) => { + const [isOpen, setIsOpen] = React.useState(false); + return ( + + + {isOpen && ( + setIsOpen(false)} + button={{text: 'Primary', onPress: onButtonPrimaryPress}} + secondaryButton={{text: 'Secondary', onPress: onButtonSecondaryPress}} + buttonLink={{text: 'Link', onPress: onButtonLinkPress}} + /> + )} + + ); +}; + +test.each(['esc', 'overlay', 'x'])('Drawer dismiss: %s', async (dismissMethod: string) => { + const onDismissSpy = jest.fn(); + render(); + + const openButton = screen.getByRole('button', {name: 'open'}); + await userEvent.click(openButton); + + const drawer = await screen.findByRole('dialog'); + + switch (dismissMethod) { + case 'esc': + await userEvent.keyboard('{Escape}'); + break; + case 'overlay': + await userEvent.click(screen.getByTestId('drawerOverlay')); + break; + case 'x': + await userEvent.click(screen.getByRole('button', {name: 'CustomDismissLabel'})); + break; + default: + throw new Error('unexpected dismiss method'); + } + + await waitFor(() => { + expect(onDismissSpy).toHaveBeenCalledTimes(1); + expect(drawer).not.toBeInTheDocument(); + }); +}); + +test.each(['primary', 'secondary', 'link'])('Drawer close: %s', async (closeMethod: string) => { + const onButtonPrimaryPress = jest.fn(); + const onButtonSecondaryPress = jest.fn(); + const onButtonLinkPress = jest.fn(); + + render( + + ); + + const openButton = screen.getByRole('button', {name: 'open'}); + await userEvent.click(openButton); + + const drawer = await screen.findByRole('dialog'); + + switch (closeMethod) { + case 'primary': + await userEvent.click(screen.getByRole('button', {name: 'Primary'})); + break; + case 'secondary': + await userEvent.click(screen.getByRole('button', {name: 'Secondary'})); + break; + case 'link': + await userEvent.click(screen.getByRole('button', {name: 'Link'})); + break; + default: + throw new Error('unexpected dismiss method'); + } + + await waitFor(() => { + expect(drawer).not.toBeInTheDocument(); + }); + + switch (closeMethod) { + case 'primary': + expect(onButtonPrimaryPress).toHaveBeenCalledTimes(1); + break; + case 'secondary': + expect(onButtonSecondaryPress).toHaveBeenCalledTimes(1); + break; + case 'link': + expect(onButtonLinkPress).toHaveBeenCalledTimes(1); + break; + default: + throw new Error('unexpected dismiss method'); + } +}); diff --git a/src/__tests__/testid-test.tsx b/src/__tests__/testid-test.tsx index 63c981c76..8f2e6f359 100644 --- a/src/__tests__/testid-test.tsx +++ b/src/__tests__/testid-test.tsx @@ -22,6 +22,7 @@ import { CoverHero, Header, MainSectionHeader, + Drawer, } from '..'; import {makeTheme} from './test-utils'; @@ -288,3 +289,12 @@ test('Meter test ids', () => { }, ]); }); + +test('Drawer test ids', () => { + checkTestIds( {}} />, [ + { + componentName: 'Drawer', + internalTestIds: ['title', 'subtitle', 'description'], + }, + ]); +}); diff --git a/src/drawer.css.ts b/src/drawer.css.ts new file mode 100644 index 000000000..41a50f7bd --- /dev/null +++ b/src/drawer.css.ts @@ -0,0 +1,103 @@ +import {style} from '@vanilla-extract/css'; +import * as mq from './media-queries.css'; +import {vars} from './skins/skin-contract.css'; +import {sprinkles} from './sprinkles.css'; + +export const ANIMATION_DURATION_MS = 350; + +export const container = style([ + sprinkles({ + position: 'fixed', + display: 'flex', + background: vars.colors.background, + top: 0, + right: 0, + bottom: 0, + overflow: 'hidden', + }), + { + paddingBottom: 'env(safe-area-inset-bottom)', + '@media': { + [mq.mobile]: { + left: 0, + transition: `transform ${ANIMATION_DURATION_MS}ms cubic-bezier(0.5, 0, 0.5, 1)`, + }, + [mq.tabletOrBigger]: { + borderTopLeftRadius: vars.borderRadii.container, + borderBottomLeftRadius: vars.borderRadii.container, + transition: `transform ${ANIMATION_DURATION_MS}ms cubic-bezier(0.5, 0, 0.5, 1)`, + maxWidth: 'calc(100vw - 40px)', + }, + }, + }, +]); + +export const drawer = style([ + sprinkles({ + paddingTop: 40, + display: 'flex', + flexDirection: 'column', + flexGrow: 1, + }), +]); + +export const titleContainer = style([ + sprinkles({ + flexShrink: 0, + flexGrow: 0, + }), + { + marginBottom: 16, + }, +]); + +export const scrollableSection = style([ + sprinkles({ + flexGrow: 1, + overflowY: 'auto', + }), +]); + +export const open = style({ + transform: '', +}); + +export const closed = style({ + '@media': { + [mq.mobile]: { + transform: 'translateY(100%)', + }, + [mq.tabletOrBigger]: { + transform: 'translateX(100%)', + }, + }, +}); + +export const overlay = style([ + sprinkles({ + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + background: vars.colors.backgroundOverlay, + }), + { + transition: `opacity ${ANIMATION_DURATION_MS}ms`, + touchAction: 'none', + }, +]); + +export const overlayClosed = style({ + opacity: 0, +}); + +export const overlayOpen = style({ + opacity: 1, +}); + +export const closeButtonContainer = sprinkles({ + position: 'absolute', + top: 8, + right: 8, +}); diff --git a/src/drawer.tsx b/src/drawer.tsx new file mode 100644 index 000000000..b09d7c77c --- /dev/null +++ b/src/drawer.tsx @@ -0,0 +1,308 @@ +// https://www.figma.com/design/NfM16IJ4ffPVEdiFbtU0Lu/%F0%9F%94%B8-Drawer-Specs?node-id=10-5397&t=58YG59t526tkk7dP-1 +'use client'; +import * as React from 'react'; +import Stack from './stack'; +import {Text3, Text4, Text5} from './text'; +import {vars} from './skins/skin-contract.css'; +import {IconButton} from './icon-button'; +import IconCloseRegular from './generated/mistica-icons/icon-close-regular'; +import Box from './box'; +import * as styles from './drawer.css'; +import classnames from 'classnames'; +import {Portal} from './portal'; +import {useIsInViewport, useScreenSize, useTheme} from './hooks'; +import FocusTrap from './focus-trap'; +import {useSetModalStateEffect} from './modal-context-provider'; +import ButtonLayout from './button-layout'; +import {ButtonLink, ButtonPrimary, ButtonSecondary} from './button'; +import Divider from './divider'; +import {getPrefixedDataAttributes} from './utils/dom'; +import * as tokens from './text-tokens'; + +import type {DataAttributes, HeadingType, TrackingEvent} from './utils/types'; + +const PADDING_X_DESKTOP = 40; +const PADDING_X_TABLET = 32; +const PADDING_X_MOBILE = 16; +const WIDTH_CONTENT = 388; +const MIN_WIDTH_DESKTOP = WIDTH_CONTENT + PADDING_X_DESKTOP * 2; +const MIN_WIDTH_TABLET = WIDTH_CONTENT + PADDING_X_TABLET * 2; + +/** + * Renders divider or a div with transparent border to avoid the small but noticeable layout shift on scroll + */ +const MaybeDivider = ({show}: {show: boolean}) => + show ? :
; + +/** + * Restores the focus to the element that was focused before the Drawer was opened + */ +const useRestoreFocus = () => { + const activeElementRef = React.useRef(document.activeElement as HTMLElement); + React.useEffect(() => { + const elementToFocus = activeElementRef.current; + return () => { + elementToFocus?.focus?.(); + }; + }, []); +}; + +type DrawerLayoutProps = { + width?: number; + children: React.ReactNode; + onClose: () => void; + onDismiss?: () => void; +}; + +type DrawerPropsRef = { + close: () => Promise; + dismiss: () => Promise; +}; + +const DrawerLayout = React.forwardRef( + ({width, children, onClose, onDismiss}, ref) => { + useSetModalStateEffect(); + useRestoreFocus(); + const {isMobile, isTablet} = useScreenSize(); + const [isOpen, setIsOpen] = React.useState(false); + const minWidthStyle = isMobile ? 'none' : isTablet ? MIN_WIDTH_TABLET : MIN_WIDTH_DESKTOP; + const widthStyle = isMobile ? 'auto' : width; + + const open = React.useCallback((node: HTMLDivElement) => { + if (node) { + // small delay to allow the Portal to be mounted + setTimeout(() => { + setIsOpen(true); + }, 50); + } + }, []); + + const close = React.useCallback(() => { + setIsOpen(false); + return new Promise((resolve) => { + setTimeout(resolve, styles.ANIMATION_DURATION_MS); + }).then(onClose); + }, [onClose]); + + const dismiss = React.useCallback(() => { + return close().then(() => onDismiss?.()); + }, [onDismiss, close]); + + React.useImperativeHandle(ref, () => ({close, dismiss})); + + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + dismiss(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + }; + }, [dismiss]); + + return ( + + + {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */} +
+
+ {children} +
+ + + ); + } +); + +type ButtonProps = { + text: string; + trackingEvent?: TrackingEvent | ReadonlyArray; + trackEvent?: boolean; + onPress?: () => unknown; +}; + +type DrawerProps = { + title?: string; + subtitle?: string; + description?: string; + titleAs?: HeadingType; + dismissLabel?: string; + /** + * this handler is mandatory. You should unmount the Drawer component on close. + */ + onClose: () => void; + /** + * set this handler to enable dismiss: + * - touching "X" + * - touching overlay + * - pressing ESC + */ + onDismiss?: () => void; + children?: React.ReactNode; + /** + * width is ignored in mobile viewport + */ + width?: number; + button?: ButtonProps; + secondaryButton?: ButtonProps; + buttonLink?: ButtonProps; + dataAttributes?: DataAttributes; +}; + +const Drawer = ({ + title, + subtitle, + description, + titleAs = 'h2', + dismissLabel, + width, + onClose, + onDismiss, + children, + button, + secondaryButton, + buttonLink, + dataAttributes, +}: DrawerProps): JSX.Element => { + const layoutRef = React.useRef(null); + const hasActions = !!(button || secondaryButton || buttonLink); + const [scrollableParentElement, setScrollableParentElement] = React.useState(null); + const topScrollSignalRef = React.useRef(null); + const bottomScrollSignalRef = React.useRef(null); + const {t, texts} = useTheme(); + + const paddingX = { + mobile: PADDING_X_MOBILE, + tablet: PADDING_X_TABLET, + desktop: PADDING_X_DESKTOP, + } as const; + + const handleButtonPress = (pressHandlerFromProps?: () => unknown) => { + layoutRef.current?.close().then(() => pressHandlerFromProps?.()); + }; + + const showTitleDivider = !useIsInViewport(topScrollSignalRef, true, { + root: scrollableParentElement, + }); + + const showButtonsDivider = !useIsInViewport(bottomScrollSignalRef, true, { + rootMargin: '1px', // bottomScrollSignal div has 0px height so we need a 1px margin to trigger the intersection observer + root: scrollableParentElement, + }); + + return ( + +
+ {onDismiss && ( +
+ layoutRef.current?.dismiss()} + Icon={IconCloseRegular} + aria-label={dismissLabel || texts.modalClose || t(tokens.modalClose)} + type="neutral" + backgroundType="transparent" + /> +
+ )} + {title && ( +
+ + + {title} + + +
+ )} + +
+
+ + + {subtitle && ( + + {subtitle} + + )} + {description && ( + + {description} + + )} + {children} + + + {!hasActions && } +
+
+ + {hasActions && ( + + handleButtonPress(button.onPress)} + > + {button.text} + + ) + } + secondaryButton={ + secondaryButton && ( + handleButtonPress(secondaryButton.onPress)} + > + {secondaryButton.text} + + ) + } + link={ + buttonLink && ( + handleButtonPress(buttonLink.onPress)} + bleedY + > + {buttonLink.text} + + ) + } + /> + + )} +
+
+ ); +}; + +export default Drawer; diff --git a/src/index.tsx b/src/index.tsx index 2fbaa37fb..20affb357 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -114,6 +114,7 @@ export { CarouselContextConsumer, } from './carousel'; export {Grid, GridItem} from './grid'; +export {default as Drawer} from './drawer'; // Sheets export {default as SheetRoot, showSheet} from './sheet-root'; diff --git a/src/portal.tsx b/src/portal.tsx index 78473c9f5..75a6225c0 100644 --- a/src/portal.tsx +++ b/src/portal.tsx @@ -20,29 +20,22 @@ export const Portal = ({children, className}: Props): JSX.Element | null => { const [container, setContainer] = React.useState(null); React.useEffect(() => { - if (!container) { - const newContainer = document.createElement('div'); - newContainer.style.isolation = 'isolate'; - setContainer(newContainer); - document.body.appendChild(newContainer); - } + const newContainer = document.createElement('div'); + newContainer.style.isolation = 'isolate'; + setContainer(newContainer); + document.body.appendChild(newContainer); return () => { - if (container) { - document.body.removeChild(container); - } + document.body.removeChild(newContainer); }; - }, [container]); + }, []); React.useEffect(() => { - if (container && className) { - container.classList.add(...className.split(' ')); - } + const classes = className?.split(' ') || []; + container?.classList.add(...classes); return () => { - if (container && className) { - container.classList.remove(...className.split(' ')); - } + container?.classList.remove(...classes); }; }, [className, container]);