Last active
February 13, 2024 21:12
-
-
Save moritzsalla/e32aaba67d12cf9543bb812c387a6af4 to your computer and use it in GitHub Desktop.
HTML modal bezier
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
@use "styles/includes.module" as *; | |
:global(::backdrop) { | |
background: #{rgba(colors.$black, 0.5)}; | |
} | |
.root { | |
--modal-margin: 0.5rem; | |
min-width: 100%; | |
min-height: 100%; | |
overflow: hidden; | |
color: var(--color-secondary); | |
background: var(--color-primary); | |
&[data-position="right"] { | |
transform: translateX(100vw); | |
} | |
&[data-position="center"] { | |
transform: translateY(100vh); | |
} | |
@include breakpoint(s) { | |
min-width: 30rem; | |
border: var(--border); | |
border-radius: var(--border-radius); | |
&[data-position="right"] { | |
top: var(--modal-margin); | |
right: var(--modal-margin); | |
left: auto; | |
min-height: calc(100% - 1rem); | |
margin: 0; | |
} | |
&[data-position="center"] { | |
top: auto; | |
bottom: var(--modal-margin); | |
min-height: max-content; | |
margin: 0 auto; | |
} | |
} | |
&[open] { | |
&[data-position="right"] { | |
animation: 0.2s $easing-smooth forwards slide-left-and-fade; | |
} | |
&[data-position="center"] { | |
animation: 0.2s $easing-smooth forwards slide-up-and-fade; | |
} | |
&::backdrop { | |
animation: 0.2s $easing-smooth forwards fade-in; | |
} | |
} | |
@include reduced-motion { | |
animation: none; | |
} | |
} | |
.inner { | |
max-height: 100dvh; | |
overflow: auto; | |
} | |
.header { | |
display: flex; | |
flex-direction: row; | |
align-items: center; | |
width: 100%; | |
@include breakpoint(s) { | |
position: absolute; | |
top: 0; | |
left: 0; | |
z-index: $z-index-above-content; | |
} | |
} | |
.closeButton { | |
@include label-2; | |
margin-left: auto; | |
padding: 1.1rem 1rem; | |
color: var(--color-secondary); | |
&:focus-visible { | |
.closeButtonLabel { | |
@include focus-default; | |
outline-offset: 0.25rem; | |
} | |
} | |
} | |
.footer { | |
position: absolute; | |
bottom: 0; | |
left: 0; | |
z-index: $z-index-above-content; | |
width: 100%; | |
padding: 1.625rem 1rem 0.6875rem; | |
&::after { | |
position: absolute; | |
bottom: 0; | |
left: 0; | |
z-index: $z-index-behind-content; | |
width: 100%; | |
height: 11.3125rem; | |
background: | |
linear-gradient( | |
180deg, | |
transparent 0%, | |
var(--color-primary) 41.44% | |
); | |
border-radius: var(--border-radius); | |
opacity: var(--gradient-opacity); | |
content: ""; | |
pointer-events: none; | |
} | |
@include breakpoint(s) { | |
padding: 1rem 1rem 1.25rem; | |
} | |
} | |
@keyframes slide-up-and-fade { | |
from { | |
transform: translateY(10%); | |
} | |
to { | |
transform: translateY(0); | |
} | |
} | |
@keyframes slide-left-and-fade { | |
from { | |
transform: translateX(10%); | |
} | |
to { | |
transform: translateX(0); | |
} | |
} | |
@keyframes fade-in { | |
from { | |
opacity: 0; | |
} | |
to { | |
opacity: 1; | |
} | |
} | |
.errorWrapper { | |
// Height 100% doesn't work - browser can't figure out the dialog element parent's height. | |
height: calc(100vh - (var(--modal-margin) * 2)); | |
} |
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
const meta: Meta<typeof Modal> = { | |
component: Modal, | |
tags: ["autodocs"], | |
args: { | |
isOpen: true, | |
position: "center", | |
closeLabel: "Close", | |
closeAriaLabel: "Close Modal", | |
}, | |
argTypes: { | |
onClose: { action: "onClose" }, | |
}, | |
render: (args) => { | |
// eslint-disable-next-line react-hooks/rules-of-hooks | |
const [showModal, setShowModal] = useState(false); | |
return ( | |
<> | |
<Button onClick={() => setShowModal(true)}> | |
<PrimaryButtonLayout>Show Modal</PrimaryButtonLayout> | |
</Button> | |
<Modal | |
isOpen={showModal} | |
closeAriaLabel={args.closeAriaLabel} | |
closeLabel={args.closeLabel} | |
position={args.position} | |
onClose={() => { | |
setShowModal(false); | |
args.onClose(); | |
}} | |
renderFooter={ | |
<div | |
style={{ | |
width: "100%", | |
height: 100, | |
display: "grid", | |
placeItems: "center", | |
color: "var(--color-secondary, red)", | |
}} | |
> | |
Footer | |
</div> | |
} | |
> | |
<div style={{ minHeight: "200vh", background: "grey" }} /> | |
</Modal> | |
</> | |
); | |
}, | |
}; | |
type Story = StoryObj<typeof Modal>; | |
export const Default: Story = {}; | |
export default meta; |
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
export type ModalPosition = "center" | "right"; | |
export type ModalOnCloseCallback = () => void; | |
export type ModalProps = React.PropsWithChildren<{ | |
isOpen: boolean; | |
position: ModalPosition; | |
onClose: ModalOnCloseCallback; | |
/** | |
* Render a sticky nav on top of the content. | |
* @example | |
* ```tsx | |
* <Modal renderHeader={<div>Modal header</div>} /> /> | |
* ``` | |
*/ | |
renderHeader?: React.ReactNode; | |
/** | |
* Render a sticky footer on top of the content. | |
* @example | |
* ```tsx | |
* <Modal renderFooter={<div>Modal Footer</div>} /> /> | |
* ``` | |
*/ | |
renderFooter?: React.ReactNode; | |
closeLabel: string; | |
closeAriaLabel: string; | |
}>; | |
export type RefElementType = React.ElementRef<"div">; | |
/** | |
* Controlled modal dialog component. | |
* | |
* @example | |
* ```tsx | |
* const [isModalOpen, setIsModalOpen] = useState(false); | |
* | |
* <Modal | |
* isOpen={isModalOpen} | |
* onClose={() => setIsModalOpen(false)} | |
* closeLabel="Close" | |
* closeAriaLabel="Close modal" | |
* renderHeader={<div>Modal header</div>} | |
* renderFooter={<div>Modal footer</div>} | |
* > | |
* <div>Modal content</div> | |
* </Modal> | |
* ``` | |
*/ | |
const Modal = forwardRef<RefElementType, ModalProps>( | |
( | |
{ | |
isOpen, | |
position = "right", | |
children, | |
closeLabel, | |
closeAriaLabel, | |
renderHeader, | |
renderFooter, | |
onClose, | |
}: ModalProps, | |
forwardedRef | |
) => { | |
const dialogRef = useRef<React.ElementRef<"dialog">>(null); | |
const [, setIsScrollLocked] = useLockScroll(); | |
useEffect(() => { | |
if (!dialogRef.current) return; | |
const close = () => { | |
dialogRef.current?.close(); | |
setIsScrollLocked(false); | |
}; | |
if (isOpen) { | |
dialogRef.current.showModal(); | |
dialogRef.current.focus(); | |
setIsScrollLocked(true); | |
} else { | |
close(); | |
dialogRef.current.blur(); | |
} | |
return close; | |
}, [isOpen, setIsScrollLocked]); | |
const handleOnClickedOutside = (e: React.MouseEvent) => { | |
if (e.target !== dialogRef.current) return; | |
onClose?.(); | |
setIsScrollLocked(false); | |
}; | |
const handleOnKeyDown = (e: React.KeyboardEvent) => { | |
if (e.key !== "Escape") return; | |
onClose?.(); | |
setIsScrollLocked(false); | |
}; | |
return ( | |
<dialog | |
ref={dialogRef} | |
aria-modal="true" | |
className={styles.root} | |
data-position={position} | |
onClick={handleOnClickedOutside} | |
onKeyDown={handleOnKeyDown} | |
> | |
<ErrorBoundary fallback={MODAL_ERROR_COMPONENT}> | |
<div ref={forwardedRef} className={styles.inner}> | |
<header className={styles.header}> | |
{renderHeader} | |
<Button | |
aria-label={closeAriaLabel} | |
className={styles.closeButton} | |
onClick={() => onClose?.()} | |
> | |
<span className={styles.closeButtonLabel}>{closeLabel}</span> | |
</Button> | |
</header> | |
{children} | |
</div> | |
{renderFooter && ( | |
<footer className={styles.footer}>{renderFooter}</footer> | |
)} | |
</ErrorBoundary> | |
</dialog> | |
); | |
} | |
); | |
const MODAL_ERROR_COMPONENT = ( | |
<div className={styles.errorContainer}> | |
<Error /> | |
</div> | |
); | |
export default Modal; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment