fix: I should be able to use "enter" key to create profile (#4978)

## Context
Fixes #4808 

TL;DR
Introducing pure stateless modal component ("UI modal") for our auth
modal not to have default hotkeyScope overriding our create-profile
hotkeyScope
+ we dont want the shortcut to be available for all the modal content, only for the input that should not be using a hotkeyscope, so we are using onKeyDown for the specific issue on create profile.

Explanation
create-profile hotkey scope is set by PageChangeEffect; CreateProfile
component adds enter key shortcut; but this scope is overwritten by the
default scope by the Modal component that expects a hotkeyScope to reset
to (and defaults to the default hotkeyScope if none indicated).
In the auth flow we were using that Modal component to give a modal look
to the flow but it is not a modal per say, it's a set of pages contained
within a modal look.
By creating this UI component we are escaping that hotkeyScope
overriding that does not make sense in our context.

## How was it tested
Locally
Storybook
This commit is contained in:
Marie
2024-04-17 10:45:02 +02:00
committed by GitHub
parent 5ecc4ad378
commit 17422b7690
7 changed files with 244 additions and 202 deletions

View File

@ -1,8 +1,10 @@
import React, { useEffect, useRef } from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { Key } from 'ts-key-enum';
import {
ModalLayout,
ModalLayoutProps,
} from '@/ui/layout/modal/components/ModalLayout';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import {
@ -12,135 +14,11 @@ import {
import { ModalHotkeyScope } from './types/ModalHotkeyScope';
const StyledModalDiv = styled(motion.div)<{
size?: ModalSize;
padding?: ModalPadding;
}>`
display: flex;
flex-direction: column;
background: ${({ theme }) => theme.background.primary};
color: ${({ theme }) => theme.font.color.primary};
border-radius: ${({ theme }) => theme.border.radius.md};
overflow: hidden;
max-height: 90vh;
z-index: 10000; // should be higher than Backdrop's z-index
width: ${({ size, theme }) => {
switch (size) {
case 'small':
return theme.modal.size.sm;
case 'medium':
return theme.modal.size.md;
case 'large':
return theme.modal.size.lg;
default:
return 'auto';
}
}};
padding: ${({ padding, theme }) => {
switch (padding) {
case 'none':
return theme.spacing(0);
case 'small':
return theme.spacing(2);
case 'medium':
return theme.spacing(4);
case 'large':
return theme.spacing(6);
default:
return 'auto';
}
}};
`;
const StyledHeader = styled.div`
align-items: center;
display: flex;
flex-direction: row;
height: 60px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(5)};
`;
const StyledContent = styled.div`
display: flex;
flex: 1;
flex: 1 1 0%;
flex-direction: column;
overflow-y: auto;
padding: ${({ theme }) => theme.spacing(10)};
`;
const StyledFooter = styled.div`
align-items: center;
display: flex;
flex-direction: row;
height: 60px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(5)};
`;
const StyledBackDrop = styled(motion.div)`
align-items: center;
background: ${({ theme }) => theme.background.overlay};
display: flex;
height: 100%;
justify-content: center;
left: 0;
position: fixed;
top: 0;
width: 100%;
z-index: 9999;
`;
/**
* Modal components
*/
type ModalHeaderProps = React.PropsWithChildren & {
className?: string;
};
const ModalHeader = ({ children, className }: ModalHeaderProps) => (
<StyledHeader className={className}>{children}</StyledHeader>
);
type ModalContentProps = React.PropsWithChildren & {
className?: string;
};
const ModalContent = ({ children, className }: ModalContentProps) => (
<StyledContent className={className}>{children}</StyledContent>
);
type ModalFooterProps = React.PropsWithChildren & {
className?: string;
};
const ModalFooter = ({ children, className }: ModalFooterProps) => (
<StyledFooter className={className}>{children}</StyledFooter>
);
/**
* Modal
*/
export type ModalSize = 'small' | 'medium' | 'large';
export type ModalPadding = 'none' | 'small' | 'medium' | 'large';
type ModalProps = React.PropsWithChildren & {
type ModalProps = ModalLayoutProps & {
isOpen?: boolean;
onClose?: () => void;
hotkeyScope?: ModalHotkeyScope;
onClose?: () => void;
onEnter?: () => void;
size?: ModalSize;
padding?: ModalPadding;
className?: string;
};
const modalVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
};
export const Modal = ({
@ -153,14 +31,6 @@ export const Modal = ({
padding = 'medium',
className,
}: ModalProps) => {
const modalRef = useRef<HTMLDivElement>(null);
useListenClickOutside({
refs: [modalRef],
callback: () => onClose?.(),
mode: ClickOutsideMode.comparePixels,
});
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
@ -196,30 +66,28 @@ export const Modal = ({
setHotkeyScopeAndMemorizePreviousScope,
]);
const modalRef = useRef<HTMLDivElement>(null);
useListenClickOutside({
refs: [modalRef],
callback: () => onClose?.(),
mode: ClickOutsideMode.comparePixels,
});
return isOpen ? (
<StyledBackDrop>
<StyledModalDiv
// framer-motion seems to have typing problems with refs
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ref={modalRef}
size={size}
padding={padding}
initial="hidden"
animate="visible"
exit="exit"
layout
variants={modalVariants}
className={className}
>
{children}
</StyledModalDiv>
</StyledBackDrop>
<ModalLayout
className={className}
modalRef={modalRef}
size={size}
padding={padding}
>
{children}
</ModalLayout>
) : (
<></>
);
};
Modal.Header = ModalHeader;
Modal.Content = ModalContent;
Modal.Footer = ModalFooter;
Modal.Header = ModalLayout.Header;
Modal.Content = ModalLayout.Content;
Modal.Footer = ModalLayout.Footer;

View File

@ -0,0 +1,168 @@
import React from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
const StyledModalDiv = styled(motion.div)<{
size?: ModalSize;
padding?: ModalPadding;
}>`
display: flex;
flex-direction: column;
background: ${({ theme }) => theme.background.primary};
color: ${({ theme }) => theme.font.color.primary};
border-radius: ${({ theme }) => theme.border.radius.md};
overflow: hidden;
max-height: 90vh;
z-index: 10000; // should be higher than Backdrop's z-index
width: ${({ size, theme }) => {
switch (size) {
case 'small':
return theme.modal.size.sm;
case 'medium':
return theme.modal.size.md;
case 'large':
return theme.modal.size.lg;
default:
return 'auto';
}
}};
padding: ${({ padding, theme }) => {
switch (padding) {
case 'none':
return theme.spacing(0);
case 'small':
return theme.spacing(2);
case 'medium':
return theme.spacing(4);
case 'large':
return theme.spacing(6);
default:
return 'auto';
}
}};
`;
const StyledHeader = styled.div`
align-items: center;
display: flex;
flex-direction: row;
height: 60px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(5)};
`;
const StyledContent = styled.div`
display: flex;
flex: 1;
flex: 1 1 0%;
flex-direction: column;
overflow-y: auto;
padding: ${({ theme }) => theme.spacing(10)};
`;
const StyledFooter = styled.div`
align-items: center;
display: flex;
flex-direction: row;
height: 60px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(5)};
`;
const StyledBackDrop = styled(motion.div)`
align-items: center;
background: ${({ theme }) => theme.background.overlay};
display: flex;
height: 100%;
justify-content: center;
left: 0;
position: fixed;
top: 0;
width: 100%;
z-index: 9999;
`;
/**
* Modal components
*/
type ModalLayoutHeaderProps = React.PropsWithChildren & {
className?: string;
};
const ModalLayoutHeader = ({ children, className }: ModalLayoutHeaderProps) => (
<StyledHeader className={className}>{children}</StyledHeader>
);
type ModalLayoutContentProps = React.PropsWithChildren & {
className?: string;
};
const ModalLayoutContent = ({
children,
className,
}: ModalLayoutContentProps) => (
<StyledContent className={className}>{children}</StyledContent>
);
type ModalLayoutFooterProps = React.PropsWithChildren & {
className?: string;
};
const ModalLayoutFooter = ({ children, className }: ModalLayoutFooterProps) => (
<StyledFooter className={className}>{children}</StyledFooter>
);
/**
* Modal
*/
export type ModalSize = 'small' | 'medium' | 'large';
export type ModalPadding = 'none' | 'small' | 'medium' | 'large';
export type ModalLayoutProps = React.PropsWithChildren & {
size?: ModalSize;
padding?: ModalPadding;
className?: string;
modalRef?: React.RefObject<HTMLElement>;
};
const modalVariants = {
hidden: { opacity: 0 },
visible: { opacity: 1 },
exit: { opacity: 0 },
};
// This component should be used over Modal when seeking a modal feel without modal state (hotkeyScope etc)
export const ModalLayout = ({
children,
size = 'medium',
padding = 'medium',
modalRef,
className,
}: ModalLayoutProps) => {
return (
<StyledBackDrop>
<StyledModalDiv
// framer-motion seems to have typing problems with refs
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ref={modalRef}
size={size}
padding={padding}
initial="hidden"
animate="visible"
exit="exit"
layout
variants={modalVariants}
className={className}
>
{children}
</StyledModalDiv>
</StyledBackDrop>
);
};
ModalLayout.Header = ModalLayoutHeader;
ModalLayout.Content = ModalLayoutContent;
ModalLayout.Footer = ModalLayoutFooter;

View File

@ -0,0 +1,36 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { ModalLayout } from '@/ui/layout/modal/components/ModalLayout';
const meta: Meta<typeof ModalLayout> = {
title: 'UI/Layout/Modal/ModalLayout',
component: ModalLayout,
};
export default meta;
type Story = StoryObj<typeof ModalLayout>;
export const Default: Story = {
args: {
size: 'medium',
padding: 'medium',
children: (
<>
<ModalLayout.Header>Stay in touch</ModalLayout.Header>
<ModalLayout.Content>
This is a dummy newletter form so don't bother trying to test it. Not
that I expect you to, anyways. :)
</ModalLayout.Content>
<ModalLayout.Footer>
By using Twenty, you're opting for the finest CRM experience you'll
ever encounter.
</ModalLayout.Footer>
</>
),
},
decorators: [ComponentDecorator],
argTypes: {
children: { control: false },
},
};