461 lines
11 KiB
TypeScript
461 lines
11 KiB
TypeScript
import { useRef } from 'react';
|
|
import styled, { type AnyStyledComponent, keyframes, css } from 'styled-components';
|
|
|
|
import {
|
|
Root,
|
|
Trigger,
|
|
Overlay,
|
|
Content,
|
|
Title,
|
|
Description,
|
|
Close,
|
|
Portal,
|
|
} from '@radix-ui/react-dialog';
|
|
|
|
import { breakpoints } from '@/styles';
|
|
import { layoutMixins } from '@/styles/layoutMixins';
|
|
|
|
import { Icon, IconName } from '@/components/Icon';
|
|
import { BackButton } from '@/components/BackButton';
|
|
|
|
import { useDialogArea } from '@/hooks/useDialogArea';
|
|
|
|
export enum DialogPlacement {
|
|
Default = 'Default',
|
|
Sidebar = 'Sidebar',
|
|
Inline = 'Inline',
|
|
FullScreen = 'FullScreen',
|
|
}
|
|
|
|
type ElementProps = {
|
|
isOpen?: boolean;
|
|
setIsOpen?: (open: boolean) => void;
|
|
slotIcon?: React.ReactNode;
|
|
title?: React.ReactNode;
|
|
description?: React.ReactNode;
|
|
onBack?: () => void;
|
|
preventClose?: boolean;
|
|
slotTrigger?: React.ReactNode;
|
|
slotHeaderInner?: React.ReactNode;
|
|
slotFooter?: React.ReactNode;
|
|
};
|
|
|
|
type StyleProps = {
|
|
placement?: DialogPlacement;
|
|
portalContainer?: HTMLElement;
|
|
hasHeaderBorder?: boolean;
|
|
children?: React.ReactNode;
|
|
className?: string;
|
|
};
|
|
|
|
const DialogPortal = ({
|
|
withPortal,
|
|
container,
|
|
children,
|
|
}: {
|
|
withPortal: boolean;
|
|
container?: HTMLElement;
|
|
children: React.ReactNode;
|
|
}) => {
|
|
const { dialogArea } = useDialogArea();
|
|
|
|
return withPortal ? (
|
|
<Portal container={container ?? dialogArea}>{children}</Portal>
|
|
) : (
|
|
<>{children}</>
|
|
);
|
|
};
|
|
|
|
export const Dialog = ({
|
|
isOpen = false,
|
|
setIsOpen,
|
|
slotIcon,
|
|
title,
|
|
description,
|
|
onBack,
|
|
preventClose,
|
|
slotTrigger,
|
|
slotHeaderInner,
|
|
slotFooter,
|
|
placement = DialogPlacement.Default,
|
|
portalContainer,
|
|
hasHeaderBorder = false,
|
|
children,
|
|
className,
|
|
}: ElementProps & StyleProps) => {
|
|
const closeButtonRef = useRef<HTMLButtonElement>();
|
|
|
|
const showOverlay = ![DialogPlacement.Inline, DialogPlacement.FullScreen].includes(placement);
|
|
|
|
return (
|
|
<Root modal={showOverlay} open={isOpen} onOpenChange={setIsOpen}>
|
|
{slotTrigger && <Trigger asChild>{slotTrigger}</Trigger>}
|
|
<DialogPortal withPortal={placement !== DialogPlacement.Inline} container={portalContainer}>
|
|
{showOverlay && <Styled.Overlay />}
|
|
<Styled.Container
|
|
placement={placement}
|
|
className={className}
|
|
onEscapeKeyDown={() => {
|
|
closeButtonRef.current?.focus();
|
|
}}
|
|
onInteractOutside={(e: Event) => {
|
|
if (!showOverlay || preventClose) {
|
|
e.preventDefault();
|
|
}
|
|
}}
|
|
>
|
|
<Styled.Header $withBorder={hasHeaderBorder}>
|
|
<Styled.HeaderTopRow>
|
|
{onBack && <BackButton onClick={onBack} />}
|
|
|
|
{slotIcon && <Styled.Icon>{slotIcon}</Styled.Icon>}
|
|
|
|
{title && <Styled.Title>{title}</Styled.Title>}
|
|
|
|
{!preventClose && (
|
|
<Styled.Close ref={closeButtonRef}>
|
|
<Icon iconName={IconName.Close} />
|
|
</Styled.Close>
|
|
)}
|
|
</Styled.HeaderTopRow>
|
|
|
|
{description && <Styled.Description>{description}</Styled.Description>}
|
|
|
|
{slotHeaderInner}
|
|
</Styled.Header>
|
|
|
|
<Styled.Content>{children}</Styled.Content>
|
|
|
|
{slotFooter && <Styled.Footer>{slotFooter}</Styled.Footer>}
|
|
</Styled.Container>
|
|
</DialogPortal>
|
|
</Root>
|
|
);
|
|
};
|
|
|
|
const Styled: Record<string, AnyStyledComponent> = {};
|
|
|
|
Styled.Overlay = styled(Overlay)`
|
|
z-index: 1;
|
|
|
|
position: fixed;
|
|
inset: 0;
|
|
|
|
pointer-events: none;
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
backdrop-filter: blur(8px);
|
|
}
|
|
|
|
@media (prefers-reduced-motion: no-preference) {
|
|
&[data-state='open'] {
|
|
animation: ${keyframes`
|
|
to {
|
|
backdrop-filter: blur(8px);
|
|
}
|
|
`} 0.15s var(--ease-out-expo) forwards;
|
|
}
|
|
|
|
&[data-state='closed'] {
|
|
animation: ${keyframes`
|
|
from {
|
|
backdrop-filter: blur(8px);
|
|
}
|
|
`} 0.15s;
|
|
}
|
|
}
|
|
`;
|
|
|
|
Styled.Container = styled(Content)<{ placement: DialogPlacement }>`
|
|
/* Params */
|
|
--dialog-inset: 1rem;
|
|
--dialog-width: 30rem;
|
|
--dialog-backgroundColor: var(--color-layer-3);
|
|
--dialog-radius: 1rem;
|
|
|
|
--dialog-paddingX: 1.5rem;
|
|
|
|
--dialog-header-z: 1;
|
|
--dialog-header-height: auto; /* set to fixed value to enable inner sticky areas */
|
|
--dialog-header-paddingTop: 1.5rem;
|
|
--dialog-header-paddingBottom: 1rem;
|
|
--dialog-header-paddingLeft: var(--dialog-paddingX);
|
|
--dialog-header-paddingRight: var(--dialog-paddingX);
|
|
|
|
--dialog-content-paddingTop: 0rem;
|
|
--dialog-content-paddingBottom: 1.5rem;
|
|
--dialog-content-paddingLeft: var(--dialog-paddingX);
|
|
--dialog-content-paddingRight: var(--dialog-paddingX);
|
|
|
|
--dialog-footer-paddingTop: 0rem;
|
|
--dialog-footer-paddingBottom: 1rem;
|
|
--dialog-footer-paddingLeft: var(--dialog-paddingX);
|
|
--dialog-footer-paddingRight: var(--dialog-paddingX);
|
|
|
|
--dialog-title-gap: 0.5rem;
|
|
--dialog-icon-size: 1.75em;
|
|
|
|
/* Calculated */
|
|
--dialog-height: calc(100% - 2 * var(--dialog-inset));
|
|
|
|
/* Rules */
|
|
${layoutMixins.scrollArea}
|
|
--scrollArea-height: var(--dialog-height);
|
|
|
|
${layoutMixins.withOuterBorder}
|
|
--border-width: var(--default-border-width);
|
|
--border-color: var(--color-border);
|
|
|
|
isolation: isolate;
|
|
z-index: 1;
|
|
position: absolute;
|
|
|
|
inset: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
|
|
${layoutMixins.stickyArea0}
|
|
--stickyArea0-topHeight: var(--dialog-header-height);
|
|
--stickyArea0-background: var(--dialog-backgroundColor);
|
|
|
|
${layoutMixins.flexColumn}
|
|
|
|
outline: none;
|
|
|
|
${({ placement }) =>
|
|
({
|
|
[DialogPlacement.Default]: css`
|
|
inset: var(--dialog-inset);
|
|
margin: auto;
|
|
|
|
max-width: var(--dialog-width);
|
|
height: fit-content;
|
|
max-height: var(--dialog-height);
|
|
|
|
display: flex;
|
|
flex-direction: column;
|
|
|
|
border-radius: var(--dialog-radius);
|
|
/* clip-path: inset(
|
|
calc(-1 * var(--border-width)) round calc(var(--dialog-radius) + var(--border-width))
|
|
);
|
|
overflow-clip-margin: var(--border-width); */
|
|
|
|
@media ${breakpoints.mobile} {
|
|
top: calc(var(--dialog-inset) * 2);
|
|
bottom: 0;
|
|
--dialog-width: initial;
|
|
width: var(--dialog-width);
|
|
|
|
margin-bottom: 0;
|
|
|
|
border-bottom-left-radius: 0;
|
|
border-bottom-right-radius: 0;
|
|
|
|
/* Hack (uneven border-radius causes overflow issues) */
|
|
/* top: auto;
|
|
bottom: calc(-1 * var(--dialog-radius));
|
|
padding-bottom: var(--dialog-radius); */
|
|
}
|
|
|
|
@media (prefers-reduced-motion: no-preference) {
|
|
&[data-state='open'] {
|
|
animation: ${keyframes`
|
|
from {
|
|
opacity: 0;
|
|
}
|
|
0.01% {
|
|
max-height: 0;
|
|
}
|
|
`} 0.15s var(--ease-out-expo);
|
|
}
|
|
|
|
&[data-state='closed'] {
|
|
animation: ${keyframes`
|
|
to {
|
|
opacity: 0;
|
|
scale: 0.9;
|
|
max-height: 0;
|
|
}
|
|
`} 0.15s;
|
|
}
|
|
}
|
|
`,
|
|
[DialogPlacement.Sidebar]: css`
|
|
--dialog-width: var(--sidebar-width);
|
|
|
|
@media ${breakpoints.notMobile} {
|
|
max-width: var(--dialog-width);
|
|
margin-left: auto;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: no-preference) {
|
|
&[data-state='open'] {
|
|
animation: ${keyframes`
|
|
from {
|
|
translate: 100% 0;
|
|
opacity: 0;
|
|
}
|
|
`} 0.15s var(--ease-out-expo);
|
|
}
|
|
|
|
&[data-state='closed'] {
|
|
animation: ${keyframes`
|
|
to {
|
|
translate: 100% 0;
|
|
opacity: 0;
|
|
}
|
|
`} 0.15s var(--ease-out-expo);
|
|
}
|
|
}
|
|
`,
|
|
[DialogPlacement.Inline]: css`
|
|
@media (prefers-reduced-motion: no-preference) {
|
|
&[data-state='open'] {
|
|
animation: ${keyframes`
|
|
from {
|
|
scale: 0.99;
|
|
opacity: 0;
|
|
/* filter: blur(2px); */
|
|
/* backdrop-filter: none; */
|
|
}
|
|
`} 0.15s var(--ease-out-expo);
|
|
}
|
|
|
|
&[data-state='closed'] {
|
|
animation: ${keyframes`
|
|
to {
|
|
scale: 0.99;
|
|
opacity: 0;
|
|
/* filter: blur(2px); */
|
|
/* backdrop-filter: none; */
|
|
}
|
|
`} 0.15s var(--ease-out-expo);
|
|
}
|
|
}
|
|
`,
|
|
[DialogPlacement.FullScreen]: css`
|
|
--dialog-width: 100vw;
|
|
--dialog-height: 100vh;
|
|
top: 0;
|
|
bottom: 0;
|
|
`,
|
|
}[placement])}
|
|
`;
|
|
|
|
Styled.Header = styled.header<{ $withBorder: boolean }>`
|
|
${layoutMixins.stickyHeader}
|
|
|
|
z-index: var(--dialog-header-z);
|
|
|
|
display: block;
|
|
padding: var(--dialog-header-paddingTop) var(--dialog-header-paddingLeft)
|
|
var(--dialog-header-paddingBottom) var(--dialog-header-paddingRight);
|
|
border-top-left-radius: inherit;
|
|
border-top-right-radius: inherit;
|
|
|
|
${({ $withBorder }) =>
|
|
$withBorder &&
|
|
css`
|
|
${layoutMixins.withOuterBorder};
|
|
background: var(--dialog-backgroundColor);
|
|
`};
|
|
`;
|
|
|
|
Styled.HeaderTopRow = styled.div`
|
|
${layoutMixins.row}
|
|
gap: var(--dialog-title-gap);
|
|
`;
|
|
|
|
Styled.HeaderTopRow = styled.div`
|
|
${layoutMixins.row}
|
|
gap: var(--dialog-title-gap);
|
|
`;
|
|
|
|
Styled.Content = styled.div`
|
|
flex: 1;
|
|
|
|
${layoutMixins.column}
|
|
|
|
${layoutMixins.stickyArea1}
|
|
--stickyArea1-background: var(--dialog-backgroundColor);
|
|
--stickyArea1-paddingTop: var(--dialog-content-paddingTop);
|
|
--stickyArea1-paddingBottom: var(--dialog-content-paddingBottom);
|
|
--stickyArea1-paddingLeft: var(--dialog-content-paddingLeft);
|
|
--stickyArea1-paddingRight: var(--dialog-content-paddingRight);
|
|
|
|
padding: var(--dialog-content-paddingTop) var(--dialog-content-paddingRight)
|
|
var(--dialog-content-paddingBottom) var(--dialog-content-paddingLeft);
|
|
|
|
isolation: isolate;
|
|
`;
|
|
|
|
Styled.Icon = styled.div`
|
|
${layoutMixins.row}
|
|
|
|
width: 1em;
|
|
height: 1em;
|
|
|
|
font-size: var(--dialog-icon-size); /* 1 line-height */
|
|
line-height: 1;
|
|
`;
|
|
|
|
Styled.Close = styled(Close)`
|
|
width: 0.7813rem;
|
|
height: 0.7813rem;
|
|
|
|
box-sizing: content-box;
|
|
padding: 0.5rem;
|
|
margin: auto 0;
|
|
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
|
|
border-radius: 0.25rem;
|
|
|
|
color: var(--color-text-0);
|
|
|
|
> svg {
|
|
height: 100%;
|
|
width: 100%;
|
|
}
|
|
|
|
&:hover,
|
|
&:focus-visible {
|
|
color: var(--color-text-2);
|
|
}
|
|
|
|
@media ${breakpoints.tablet} {
|
|
width: 1rem;
|
|
height: 1rem;
|
|
outline: none;
|
|
}
|
|
`;
|
|
|
|
Styled.Title = styled(Title)`
|
|
flex: 1;
|
|
|
|
font: var(--font-large-medium);
|
|
color: var(--color-text-2);
|
|
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
`;
|
|
|
|
Styled.Description = styled(Description)`
|
|
margin-top: 0.5rem;
|
|
color: var(--color-text-0);
|
|
font: var(--font-base-book);
|
|
`;
|
|
|
|
Styled.Footer = styled.footer`
|
|
display: grid;
|
|
${layoutMixins.stickyFooter}
|
|
${layoutMixins.withStickyFooterBackdrop}
|
|
--stickyFooterBackdrop-outsetX: var(--dialog-paddingX);
|
|
|
|
padding: var(--dialog-footer-paddingTop) var(--dialog-footer-paddingLeft)
|
|
var(--dialog-footer-paddingBottom) var(--dialog-footer-paddingRight);
|
|
`;
|