Skip to content

Commit

Permalink
Add Drawer to DS (#3903)
Browse files Browse the repository at this point in the history
* Add Drawer to DS

* Revert a change

* Linting
  • Loading branch information
emmatown committed Oct 8, 2020
1 parent 32e9ee0 commit f7f3cbf
Show file tree
Hide file tree
Showing 13 changed files with 543 additions and 1 deletion.
24 changes: 24 additions & 0 deletions design-system/packages/modals/package.json
@@ -0,0 +1,24 @@
{
"name": "@keystone-ui/modals",
"version": "1.0.0",
"private": true,
"license": "MIT",
"main": "dist/modals.cjs.js",
"module": "dist/modals.esm.js",
"devDependencies": {
"@types/react": "^16.9.51"
},
"dependencies": {
"@babel/runtime": "^7.11.2",
"@keystone-ui/button": "*",
"@keystone-ui/core": "*",
"react": "^16.13.1",
"react-focus-lock": "^2.4.1",
"react-remove-scroll": "^2.4.0",
"react-transition-group": "^4.4.1"
},
"engines": {
"node": ">=10.0.0"
},
"repository": "https://github.com/keystonejs/keystone/tree/master/design-system/packages/modals"
}
32 changes: 32 additions & 0 deletions design-system/packages/modals/src/Blanket.tsx
@@ -0,0 +1,32 @@
/** @jsx jsx */

import { HTMLAttributes, forwardRef } from 'react';
import { jsx, keyframes, useTheme } from '@keystone-ui/core';

const fadeInAnim = keyframes({
from: {
opacity: 0,
},
});
const easing = 'cubic-bezier(0.2, 0, 0, 1)';

export const Blanket = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>((props, ref) => {
const theme = useTheme();

return (
<div
ref={ref}
css={{
animation: `${fadeInAnim} 320ms ${easing}`,
backgroundColor: 'rgba(0, 0, 0, 0.3)', // TODO get this from the theme
bottom: 0,
left: 0,
position: 'fixed',
right: 0,
top: 0,
zIndex: theme.elevation.e400,
}}
{...props}
/>
);
});
75 changes: 75 additions & 0 deletions design-system/packages/modals/src/Drawer.tsx
@@ -0,0 +1,75 @@
/** @jsx jsx */

import { MutableRefObject, ReactNode } from 'react';
import { Button } from '@keystone-ui/button';
import { jsx, makeId, useId, useTheme, Heading } from '@keystone-ui/core';

import { DrawerBase } from './DrawerBase';
import { useDrawerControllerContext } from './DrawerController';
import { ActionsType } from './types';

type DrawerProps = {
actions: ActionsType;
children: ReactNode;
id?: string;
initialFocusRef?: MutableRefObject<any>;
title: string;
width?: 'narrow' | 'wide';
};

export const Drawer = ({
actions,
children,
title,
id,
initialFocusRef,
width = 'narrow',
}: DrawerProps) => {
const transitionState = useDrawerControllerContext();
const { cancel, confirm } = actions;
const { spacing } = useTheme();

const safeClose = actions.confirm.loading ? () => {} : actions.cancel.action;

const instanceId = useId(id);
const headingId = makeId(instanceId, 'heading');

return (
<DrawerBase
transitionState={transitionState}
aria-labelledby={headingId}
initialFocusRef={initialFocusRef}
onSubmit={actions.confirm.action}
onClose={safeClose}
width={width}
>
<div css={{ padding: `${spacing.large}px ${spacing.xlarge}px` }}>
<Heading id={headingId} type="h3">
{title}
</Heading>
</div>

<div css={{ overflowY: 'auto', padding: `0 ${spacing.xlarge}px` }}>{children}</div>

<div
css={{
display: 'flex',
flexShrink: 0,
flexDirection: 'column',
padding: `${spacing.large}px ${spacing.xlarge}px`,

'> button + button': {
marginTop: spacing.small,
},
}}
>
<Button tone="active" weight="bold" type="submit" isLoading={confirm.loading}>
{confirm.label}
</Button>
<Button onClick={safeClose} disabled={confirm.loading} weight="none" tone="passive">
{cancel.label}
</Button>
</div>
</DrawerBase>
);
};
144 changes: 144 additions & 0 deletions design-system/packages/modals/src/DrawerBase.tsx
@@ -0,0 +1,144 @@
/** @jsx jsx */

import { Fragment, KeyboardEvent, MutableRefObject, ReactNode, useCallback, useRef } from 'react';
import FocusLock from 'react-focus-lock';
import { RemoveScroll } from 'react-remove-scroll';
import { makeId, useId, useTheme, Portal, jsx } from '@keystone-ui/core';
import { Blanket } from './Blanket';

import { useDrawerManager } from './drawer-context';
import { TransitionState } from './types';
import { DrawerControllerContextProvider } from './DrawerController';

const widths = {
narrow: 448,
wide: 720,
};
const easing = 'cubic-bezier(0.2, 0, 0, 1)';

export type DrawerBaseProps = {
children: ReactNode;
initialFocusRef?: MutableRefObject<any>;
onClose: () => void;
transitionState: TransitionState;
onSubmit?: () => void;
width?: keyof typeof widths;
};

const blanketTransition = {
entering: { opacity: 0 },
entered: { opacity: 1 },
exiting: { opacity: 0 },
exited: { opacity: 0 },
};

export const DrawerBase = ({
children,
initialFocusRef,
onClose,
onSubmit,
width = 'narrow',
transitionState,
...props
}: DrawerBaseProps) => {
const theme = useTheme();
const containerRef = useRef(null);

const id = useId();
const uniqueKey = makeId('drawer', id);

// sync drawer state
let drawerDepth = useDrawerManager(uniqueKey);

const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && !event.defaultPrevented) {
event.preventDefault();
onClose();
}
};

const activateFocusLock = useCallback(() => {
if (initialFocusRef && initialFocusRef.current) {
initialFocusRef.current.focus();
}
}, [initialFocusRef]);

const dialogTransition = getDialogTransition(drawerDepth);

let Tag: 'div' | 'form' = 'div';
if (onSubmit) {
Tag = 'form';
let oldOnSubmit = onSubmit;
// @ts-ignore
onSubmit = (event: any) => {
if (!event.defaultPrevented) {
event.preventDefault();
oldOnSubmit();
}
};
}

return (
<Portal>
<Fragment>
<Blanket
onClick={onClose}
style={{
transition: `opacity 150ms linear`,
...blanketTransition[transitionState],
zIndex: theme.elevation.e400,
}}
/>
<FocusLock autoFocus returnFocus onActivation={activateFocusLock}>
<RemoveScroll enabled>
<Tag
onSubmit={onSubmit}
aria-modal="true"
role="dialog"
ref={containerRef}
tabIndex={-1}
onKeyDown={onKeyDown}
style={dialogTransition[transitionState]}
css={{
backgroundColor: theme.colors.background,
borderRadius: theme.radii.large,
bottom: theme.spacing.small,
boxShadow: theme.shadow.s400,
position: 'fixed',
right: theme.spacing.small,
top: theme.spacing.small,
transition: `transform 150ms ${easing}`,
width: widths[width],
zIndex: theme.elevation.e400,

// flex layout must be applied here so content will grow/shrink properly
display: 'flex',
flexDirection: 'column',
}}
{...props}
>
<DrawerControllerContextProvider value={null}>
{children}
</DrawerControllerContextProvider>
</Tag>
</RemoveScroll>
</FocusLock>
</Fragment>
</Portal>
);
};

// Utils
// ------------------------------

function getDialogTransition(depth: number) {
let scaleInc = 0.05;
let transformValue = `scale(${1 - scaleInc * depth}) translateX(-${depth * 40}px)`;

return {
entering: { transform: 'translateX(100%)' },
entered: { transform: transformValue },
exiting: { transform: 'translateX(100%)' },
exited: { transform: 'translateX(100%)' },
};
}
36 changes: 36 additions & 0 deletions design-system/packages/modals/src/DrawerController.tsx
@@ -0,0 +1,36 @@
import React, { ReactNode, useContext } from 'react';
import { Transition } from 'react-transition-group';

import { TransitionState } from './types';

type DrawerControllerProps = {
isOpen: boolean;
children: ReactNode;
};

const DrawerControllerContext = React.createContext<null | TransitionState>(null);

export const DrawerControllerContextProvider = DrawerControllerContext.Provider;

export const useDrawerControllerContext = () => {
let context = useContext(DrawerControllerContext);
if (!context) {
throw new Error(
'Drawers must be wrapped in a <DrawerController>. You should generally do this outside of the component that renders the <Drawer> or <TabbedDrawer>.'
);
}

return context;
};

export const DrawerController = ({ isOpen, children }: DrawerControllerProps) => {
return (
<Transition appear mountOnEnter unmountOnExit in={isOpen} timeout={150}>
{(transitionState: TransitionState) => (
<DrawerControllerContextProvider value={transitionState}>
{children}
</DrawerControllerContextProvider>
)}
</Transition>
);
};
59 changes: 59 additions & 0 deletions design-system/packages/modals/src/drawer-context.tsx
@@ -0,0 +1,59 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react';

export type ModalState = {
drawerStack: string[];
pushToDrawerStack: (drawerKey: string) => void;
popFromDrawerStack: () => void;
};

const ModalContext = React.createContext<ModalState | null>(null);

export const DrawerProvider = ({ children }: { children: ReactNode }) => {
let [drawerStack, setDrawerStack] = useState<string[]>([]);

const pushToDrawerStack = useCallback((key: string) => {
setDrawerStack(stack => [...stack, key]);
}, []);
const popFromDrawerStack = useCallback(() => {
setDrawerStack(stack => {
let less = stack.slice(0, -1);
return less;
});
}, []);

const context = {
drawerStack,
pushToDrawerStack,
popFromDrawerStack,
};

return <ModalContext.Provider value={context}>{children}</ModalContext.Provider>;
};

// Utils
// ------------------------------
export const useDrawerManager = (uniqueKey: string) => {
const modalState = React.useContext(ModalContext);

if (modalState === null) {
throw new Error(
'This component must have a <DrawerProvider/> ancestor in the same React tree.'
);
}

// keep the stack in sync on mount/unmount
useEffect(() => {
modalState.pushToDrawerStack(uniqueKey);
return () => {
modalState.popFromDrawerStack();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// the last key in the array is the "top" modal visually, so the depth is the inverse index
// be careful not to mutate the stack
let depth = modalState.drawerStack.slice().reverse().indexOf(uniqueKey);
// if it's not in the stack already,
// we know that it should be the last drawer in the stack but the effect hasn't happened yet
// so we need to make the depth 0 so the depth is correct even though the effect hasn't happened yet
return depth === -1 ? 0 : depth;
};
3 changes: 3 additions & 0 deletions design-system/packages/modals/src/index.tsx
@@ -0,0 +1,3 @@
export { Drawer } from './Drawer';
export { DrawerProvider } from './drawer-context';
export { DrawerController } from './DrawerController';
13 changes: 13 additions & 0 deletions design-system/packages/modals/src/types.ts
@@ -0,0 +1,13 @@
type Action = {
action: () => void;
label: string;
};

export type ActionsType = {
cancel: Action;
confirm: Action & {
loading?: boolean;
};
};

export type TransitionState = 'entering' | 'entered' | 'exiting' | 'exited';

0 comments on commit f7f3cbf

Please sign in to comment.