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

Add Drawer to DS #3903

Merged
merged 3 commits into from Oct 8, 2020
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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';