Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Add Drawer to DS * Revert a change * Linting
- Loading branch information
Showing
13 changed files
with
543 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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} | ||
/> | ||
); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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%)' }, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export { Drawer } from './Drawer'; | ||
export { DrawerProvider } from './drawer-context'; | ||
export { DrawerController } from './DrawerController'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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'; |
Oops, something went wrong.