revite/src/components/ui/Modal.tsx

224 lines
5.2 KiB
TypeScript
Raw Normal View History

2021-07-05 11:23:23 +01:00
import styled, { css, keyframes } from "styled-components";
2021-07-07 22:02:18 +01:00
import { createPortal, useEffect, useState } from "preact/compat";
2021-07-05 11:23:23 +01:00
import { internalSubscribe } from "../../lib/eventEmitter";
2021-07-05 11:23:23 +01:00
import { Children } from "../../types/Preact";
2021-07-06 11:34:36 +01:00
import Button, { ButtonProps } from "./Button";
2021-06-19 18:46:05 +01:00
const open = keyframes`
0% {opacity: 0;}
70% {opacity: 0;}
100% {opacity: 1;}
`;
2021-07-07 22:02:18 +01:00
const close = keyframes`
0% {opacity: 1;}
70% {opacity: 0;}
100% {opacity: 0;}
`;
2021-06-19 18:46:05 +01:00
const zoomIn = keyframes`
0% {transform: scale(0.5);}
98% {transform: scale(1.01);}
100% {transform: scale(1);}
`;
2021-07-07 22:02:18 +01:00
const zoomOut = keyframes`
0% {transform: scale(1);}
100% {transform: scale(0.5);}
`;
2021-06-19 18:46:05 +01:00
const ModalBase = styled.div`
2021-07-05 11:25:20 +01:00
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
position: fixed;
max-height: 100%;
user-select: none;
animation-name: ${open};
animation-duration: 0.2s;
display: grid;
overflow-y: auto;
place-items: center;
color: var(--foreground);
background: rgba(0, 0, 0, 0.8);
2021-07-07 22:02:18 +01:00
&.closing {
animation-name: ${close};
}
2021-07-07 22:02:18 +01:00
&.closing > div {
animation-name: ${zoomOut};
}
2021-06-19 18:46:05 +01:00
`;
const ModalContainer = styled.div`
2021-07-05 11:25:20 +01:00
overflow: hidden;
max-width: calc(100vw - 20px);
2021-07-10 15:42:13 +01:00
border-radius: var(--border-radius);
2021-06-19 18:46:05 +01:00
2021-07-05 11:25:20 +01:00
animation-name: ${zoomIn};
animation-duration: 0.25s;
animation-timing-function: cubic-bezier(0.3, 0.3, 0.18, 1.1);
2021-06-19 18:46:05 +01:00
`;
2021-07-05 11:23:23 +01:00
const ModalContent = styled.div<
2021-07-05 11:25:20 +01:00
{ [key in "attachment" | "noBackground" | "border" | "padding"]?: boolean }
2021-07-05 11:23:23 +01:00
>`
2021-07-05 11:25:20 +01:00
text-overflow: ellipsis;
2021-07-10 15:42:13 +01:00
border-radius: var(--border-radius);
2021-07-05 11:25:20 +01:00
h3 {
margin-top: 0;
}
form {
display: flex;
flex-direction: column;
}
${(props) =>
!props.noBackground &&
css`
background: var(--secondary-header);
`}
${(props) =>
props.padding &&
css`
padding: 1.5em;
`}
2021-07-05 11:23:23 +01:00
${(props) =>
2021-07-05 11:25:20 +01:00
props.attachment &&
css`
2021-07-10 15:42:13 +01:00
border-radius: var(--border-radius) var(--border-radius) 0 0;
2021-07-05 11:25:20 +01:00
`}
2021-07-05 11:23:23 +01:00
${(props) =>
2021-07-05 11:25:20 +01:00
props.border &&
css`
2021-07-10 15:42:13 +01:00
border-radius: var(--border-radius);
2021-07-05 11:25:20 +01:00
border: 2px solid var(--secondary-background);
`}
2021-06-19 18:46:05 +01:00
`;
const ModalActions = styled.div`
2021-07-05 11:25:20 +01:00
gap: 8px;
display: flex;
flex-direction: row-reverse;
2021-06-19 18:46:05 +01:00
2021-07-05 11:25:20 +01:00
padding: 1em 1.5em;
border-radius: 0 0 8px 8px;
background: var(--secondary-background);
2021-06-19 18:46:05 +01:00
`;
2021-07-06 19:29:27 +01:00
export type Action = Omit<ButtonProps, "onClick"> & {
2021-07-05 11:25:20 +01:00
confirmation?: boolean;
2021-07-06 11:34:36 +01:00
onClick: () => void;
2021-07-06 19:29:27 +01:00
};
2021-06-19 18:46:05 +01:00
interface Props {
2021-07-05 11:25:20 +01:00
children?: Children;
title?: Children;
disallowClosing?: boolean;
noBackground?: boolean;
dontModal?: boolean;
padding?: boolean;
onClose: () => void;
actions?: Action[];
disabled?: boolean;
border?: boolean;
visible: boolean;
2021-06-19 18:46:05 +01:00
}
2021-07-07 22:02:18 +01:00
export let isModalClosing = false;
2021-06-19 18:46:05 +01:00
export default function Modal(props: Props) {
2021-07-05 11:25:20 +01:00
if (!props.visible) return null;
const content = (
2021-07-05 11:25:20 +01:00
<ModalContent
attachment={!!props.actions}
noBackground={props.noBackground}
border={props.border}
padding={props.padding ?? !props.dontModal}>
{props.title && <h3>{props.title}</h3>}
{props.children}
</ModalContent>
);
if (props.dontModal) {
return content;
}
2021-07-07 22:02:18 +01:00
const [animateClose, setAnimateClose] = useState(false);
isModalClosing = animateClose;
function onClose() {
setAnimateClose(true);
setTimeout(() => props.onClose(), 2e2);
}
useEffect(() => internalSubscribe("Modal", "close", onClose), []);
2021-07-07 22:02:18 +01:00
2021-07-05 11:25:20 +01:00
useEffect(() => {
if (props.disallowClosing) return;
function keyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
2021-07-07 22:02:18 +01:00
onClose();
2021-07-05 11:25:20 +01:00
}
}
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, [props.disallowClosing, props.onClose]);
const confirmationAction = props.actions?.find(
2021-07-05 11:25:20 +01:00
(action) => action.confirmation,
);
2021-07-07 22:02:18 +01:00
2021-07-05 11:25:20 +01:00
useEffect(() => {
if (!confirmationAction) return;
// ! TODO: this may be done better if we
2021-07-05 11:25:20 +01:00
// ! can focus the button although that
// ! doesn't seem to work...
function keyDown(e: KeyboardEvent) {
if (e.key === "Enter") {
confirmationAction!.onClick();
}
}
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, [confirmationAction]);
return createPortal(
<ModalBase
className={animateClose ? "closing" : undefined}
2021-07-05 11:25:20 +01:00
onClick={(!props.disallowClosing && props.onClose) || undefined}>
<ModalContainer onClick={(e) => (e.cancelBubble = true)}>
{content}
{props.actions && (
<ModalActions>
2021-07-06 19:29:27 +01:00
{props.actions.map((x) => (
2021-07-06 11:34:36 +01:00
<Button {...x} disabled={props.disabled} />
2021-07-06 19:29:27 +01:00
))}
2021-07-05 11:25:20 +01:00
</ModalActions>
)}
</ModalContainer>
</ModalBase>,
document.body,
);
2021-06-19 18:46:05 +01:00
}