Skip to content

Instantly share code, notes, and snippets.

@moritzsalla
Last active February 13, 2024 21:12
Show Gist options
  • Save moritzsalla/e32aaba67d12cf9543bb812c387a6af4 to your computer and use it in GitHub Desktop.
Save moritzsalla/e32aaba67d12cf9543bb812c387a6af4 to your computer and use it in GitHub Desktop.
HTML modal bezier
@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));
}
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;
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