feat: wip import csv [part 1] (#1033)

* feat: wip import csv

* feat: start implementing twenty UI

* feat: new radio button component

* feat: use new radio button component and fix scroll issue

* fix: max height modal

* feat: wip try to customize react-data-grid to match design

* feat: wip match columns

* feat: wip match column selection

* feat: match column

* feat: clean heading component & try to fix scroll in last step

* feat: validation step

* fix: small cleaning and remove unused component

* feat: clean folder architecture

* feat: remove translations

* feat: remove chackra theme

* feat: remove unused libraries

* feat: use option button to open spreadsheet & fix stories

* Fix lint and fix imports

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Jérémy M
2023-08-16 00:12:47 +02:00
committed by GitHub
parent 1ca41021cf
commit 56cada6335
95 changed files with 7042 additions and 99 deletions

View File

@ -38,7 +38,9 @@ type OwnProps = {
const StyledColorSample = styled.div<{ colorName: string }>`
background-color: ${({ theme, colorName }) =>
theme.tag.background[colorName]};
border: 1px solid ${({ theme, colorName }) => theme.color[colorName]};
border: 1px solid
${({ theme, colorName }) =>
theme.color[colorName as keyof typeof theme.color]};
border-radius: ${({ theme }) => theme.border.radius.sm};
height: 12px;
width: 12px;

View File

@ -1,5 +1,6 @@
import React from 'react';
import React, { useMemo } from 'react';
import styled from '@emotion/styled';
import { TablerIconsProps } from '@tabler/icons-react';
import { SoonPill } from '@/ui/pill/components/SoonPill';
import { rgba } from '@/ui/theme/constants/colors';
@ -58,6 +59,8 @@ const StyledButton = styled.button<
case 'primary':
case 'secondary':
return `${theme.background.transparent.medium}`;
case 'danger':
return `${theme.border.color.danger}`;
case 'tertiary':
default:
return 'none';
@ -80,6 +83,7 @@ const StyledButton = styled.button<
switch (variant) {
case 'primary':
case 'secondary':
case 'danger':
return position === 'middle' ? `1px 0 1px 0` : `1px`;
case 'tertiary':
default:
@ -98,10 +102,13 @@ const StyledButton = styled.button<
color: ${({ theme, variant, disabled }) => {
if (disabled) {
if (variant === 'primary') {
return theme.color.gray0;
} else {
return theme.font.color.extraLight;
switch (variant) {
case 'primary':
return theme.color.gray0;
case 'danger':
return theme.border.color.danger;
default:
return theme.font.color.extraLight;
}
}
@ -156,6 +163,8 @@ const StyledButton = styled.button<
switch (variant) {
case 'primary':
return `background: linear-gradient(0deg, ${theme.background.transparent.medium} 0%, ${theme.background.transparent.medium} 100%), ${theme.color.blue}`;
case 'danger':
return `background: ${theme.background.transparent.danger}`;
default:
return `background: ${theme.background.tertiary}`;
}
@ -178,7 +187,7 @@ const StyledButton = styled.button<
`;
export function Button({
icon,
icon: initialIcon,
title,
fullWidth = false,
variant = ButtonVariant.Primary,
@ -188,6 +197,16 @@ export function Button({
disabled = false,
...props
}: ButtonProps) {
const icon = useMemo(() => {
if (!initialIcon || !React.isValidElement(initialIcon)) {
return null;
}
return React.cloneElement<TablerIconsProps>(initialIcon as any, {
size: 14,
});
}, [initialIcon]);
return (
<StyledButton
fullWidth={fullWidth}

View File

@ -0,0 +1,38 @@
import { motion } from 'framer-motion';
export type CheckmarkProps = React.ComponentProps<typeof motion.path> & {
isAnimating?: boolean;
color?: string;
duration?: number;
size?: number;
};
export function AnimatedCheckmark({
isAnimating = false,
color = '#FFF',
duration = 0.5,
size = 28,
...restProps
}: CheckmarkProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 52 52"
width={size}
height={size}
>
<motion.path
{...restProps}
fill="none"
stroke={color}
strokeWidth={4}
d="M14 27l7.8 7.8L38 14"
pathLength="1"
strokeDasharray="1"
strokeDashoffset={isAnimating ? '1' : '0'}
animate={{ strokeDashoffset: isAnimating ? '0' : '1' }}
transition={{ duration }}
/>
</svg>
);
}

View File

@ -0,0 +1,125 @@
import { useCallback } from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { Button, ButtonVariant } from '@/ui/button/components/Button';
const DialogOverlay = styled(motion.div)`
align-items: center;
background: ${({ theme }) => theme.background.overlay};
display: flex;
height: 100vh;
justify-content: center;
left: 0;
position: fixed;
top: 0;
width: 100vw;
z-index: 9999;
`;
const DialogContainer = styled(motion.div)`
background: ${({ theme }) => theme.background.primary};
border-radius: 8px;
display: flex;
flex-direction: column;
max-width: 320px;
padding: 2em;
position: relative;
width: 100%;
`;
const DialogTitle = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(6)};
text-align: center;
`;
const DialogMessage = styled.span`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-bottom: ${({ theme }) => theme.spacing(6)};
text-align: center;
`;
const DialogButton = styled(Button)`
justify-content: center;
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
export type DialogButtonOptions = Omit<
React.ComponentProps<typeof Button>,
'fullWidth'
>;
export type DialogProps = React.ComponentPropsWithoutRef<typeof motion.div> & {
title?: string;
message?: string;
buttons?: DialogButtonOptions[];
allowDismiss?: boolean;
children?: React.ReactNode;
onClose?: () => void;
};
export function Dialog({
title,
message,
buttons = [],
allowDismiss = true,
children,
onClose,
...rootProps
}: DialogProps) {
const closeSnackbar = useCallback(() => {
onClose && onClose();
}, [onClose]);
const dialogVariants = {
open: { opacity: 1 },
closed: { opacity: 0 },
};
const containerVariants = {
open: { y: 0 },
closed: { y: '50vh' },
};
return (
<DialogOverlay
variants={dialogVariants}
initial="closed"
animate="open"
exit="closed"
onClick={(e) => {
if (allowDismiss) {
e.stopPropagation();
closeSnackbar();
}
}}
>
<DialogContainer
variants={containerVariants}
transition={{ damping: 15, stiffness: 100 }}
{...rootProps}
>
{title && <DialogTitle>{title}</DialogTitle>}
{message && <DialogMessage>{message}</DialogMessage>}
{children}
{buttons.map((button) => (
<DialogButton
key={button.title}
onClick={(e) => {
button?.onClick?.(e);
closeSnackbar();
}}
fullWidth={true}
variant={button.variant ?? ButtonVariant.Secondary}
{...button}
/>
))}
</DialogContainer>
</DialogOverlay>
);
}

View File

@ -0,0 +1,26 @@
import { useRecoilState } from 'recoil';
import { dialogInternalState } from '../states/dialogState';
import { Dialog } from './Dialog';
export function DialogProvider({ children }: React.PropsWithChildren) {
const [dialogState, setDialogState] = useRecoilState(dialogInternalState);
// Handle dialog close event
const handleDialogClose = (id: string) => {
setDialogState((prevState) => ({
...prevState,
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
}));
};
return (
<>
{children}
{dialogState.queue.map((dialog) => (
<Dialog {...dialog} onClose={() => handleDialogClose(dialog.id)} />
))}
</>
);
}

View File

@ -0,0 +1,17 @@
import { useSetRecoilState } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { DialogOptions, dialogSetQueueState } from '../states/dialogState';
export function useDialog() {
const setDialogQueue = useSetRecoilState(dialogSetQueueState);
const enqueueDialog = (options?: Omit<DialogOptions, 'id'>) => {
setDialogQueue({
id: uuidv4(),
...options,
});
};
return { enqueueDialog };
}

View File

@ -0,0 +1,39 @@
import { atom, selector } from 'recoil';
import { DialogProps } from '../components/Dialog';
export type DialogOptions = DialogProps & {
id: string;
};
export type DialogState = {
maxQueue: number;
queue: DialogOptions[];
};
export const dialogInternalState = atom<DialogState>({
key: 'dialog/internal-state',
default: {
maxQueue: 2,
queue: [],
},
});
export const dialogSetQueueState = selector<DialogOptions | null>({
key: 'dialog/queue-state',
get: ({ get: _get }) => null, // We don't care about getting the value
set: ({ set }, newValue) =>
set(dialogInternalState, (prev) => {
if (prev.queue.length >= prev.maxQueue) {
return {
...prev,
queue: [...prev.queue.slice(1), newValue] as DialogOptions[],
};
}
return {
...prev,
queue: [...prev.queue, newValue] as DialogOptions[],
};
}),
});

View File

@ -7,13 +7,15 @@ import { hoverBackground } from '@/ui/theme/constants/effects';
import { DropdownMenuItem } from './DropdownMenuItem';
type Props = {
type Props = React.ComponentProps<'li'> & {
selected?: boolean;
onClick: () => void;
hovered?: boolean;
disabled?: boolean;
};
const DropdownMenuSelectableItemContainer = styled(DropdownMenuItem)<Props>`
const DropdownMenuSelectableItemContainer = styled(DropdownMenuItem)<
Pick<Props, 'hovered'>
>`
${hoverBackground};
align-items: center;
@ -27,12 +29,15 @@ const DropdownMenuSelectableItemContainer = styled(DropdownMenuItem)<Props>`
width: calc(100% - ${({ theme }) => theme.spacing(2)});
`;
const StyledLeftContainer = styled.div`
const StyledLeftContainer = styled.div<Pick<Props, 'disabled'>>`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
opacity: ${({ disabled }) => (disabled ? 0.5 : 1)};
overflow: hidden;
`;
@ -45,9 +50,19 @@ export function DropdownMenuSelectableItem({
onClick,
children,
hovered,
disabled,
...restProps
}: React.PropsWithChildren<Props>) {
const theme = useTheme();
function handleClick(event: React.MouseEvent<HTMLLIElement>) {
if (disabled) {
return;
}
onClick?.(event);
}
useEffect(() => {
if (hovered) {
window.scrollTo({
@ -58,12 +73,12 @@ export function DropdownMenuSelectableItem({
return (
<DropdownMenuSelectableItemContainer
onClick={onClick}
selected={selected}
{...restProps}
onClick={handleClick}
hovered={hovered}
data-testid="dropdown-menu-item"
>
<StyledLeftContainer>{children}</StyledLeftContainer>
<StyledLeftContainer disabled={disabled}>{children}</StyledLeftContainer>
<StyledRightIcon>
{selected && <IconCheck size={theme.icon.size.md} />}
</StyledRightIcon>

View File

@ -1,8 +1,8 @@
export { IconAddressBook } from './components/IconAddressBook';
export {
IconAlertCircle,
IconAlertTriangle,
IconArchive,
IconArrowBack,
IconArrowNarrowDown,
IconArrowNarrowUp,
IconArrowRight,
@ -26,10 +26,13 @@ export {
IconColorSwatch,
IconMessageCircle as IconComment,
IconCopy,
IconCross,
IconCurrencyDollar,
IconEye,
IconEyeOff,
IconFileImport,
IconFileUpload,
IconForbid,
IconHeart,
IconHelpCircle,
IconInbox,

View File

@ -6,6 +6,7 @@ import { IconCheck, IconMinus } from '@/ui/icon';
export enum CheckboxVariant {
Primary = 'primary',
Secondary = 'secondary',
Tertiary = 'tertiary',
}
export enum CheckboxShape {
@ -21,7 +22,8 @@ export enum CheckboxSize {
type OwnProps = {
checked: boolean;
indeterminate?: boolean;
onChange?: (value: boolean) => void;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
onCheckedChange?: (value: boolean) => void;
variant?: CheckboxVariant;
size?: CheckboxSize;
shape?: CheckboxShape;
@ -33,13 +35,15 @@ const StyledInputContainer = styled.div`
position: relative;
`;
const StyledInput = styled.input<{
type InputProps = {
checkboxSize: CheckboxSize;
variant: CheckboxVariant;
indeterminate?: boolean;
shape?: CheckboxShape;
isChecked: boolean;
}>`
isChecked?: boolean;
};
const StyledInput = styled.input<InputProps>`
cursor: pointer;
margin: 0;
opacity: 0;
@ -61,18 +65,25 @@ const StyledInput = styled.input<{
checkboxSize === CheckboxSize.Large ? '18px' : '12px'};
background: ${({ theme, indeterminate, isChecked }) =>
indeterminate || isChecked ? theme.color.blue : 'transparent'};
border-color: ${({ theme, indeterminate, isChecked, variant }) =>
indeterminate || isChecked
? theme.color.blue
: variant === CheckboxVariant.Primary
? theme.border.color.inverted
: theme.border.color.secondaryInverted};
border-color: ${({ theme, indeterminate, isChecked, variant }) => {
switch (true) {
case indeterminate || isChecked:
return theme.color.blue;
case variant === CheckboxVariant.Primary:
return theme.border.color.inverted;
case variant === CheckboxVariant.Tertiary:
return theme.border.color.medium;
default:
return theme.border.color.secondaryInverted;
}
}};
border-radius: ${({ theme, shape }) =>
shape === CheckboxShape.Rounded
? theme.border.radius.rounded
: theme.border.radius.sm};
border-style: solid;
border-width: 1px;
border-width: ${({ variant }) =>
variant === CheckboxVariant.Tertiary ? '2px' : '1px'};
content: '';
cursor: pointer;
display: inline-block;
@ -81,8 +92,11 @@ const StyledInput = styled.input<{
}
& + label > svg {
--padding: ${({ checkboxSize }) =>
checkboxSize === CheckboxSize.Large ? '2px' : '1px'};
--padding: ${({ checkboxSize, variant }) =>
checkboxSize === CheckboxSize.Large ||
variant === CheckboxVariant.Tertiary
? '2px'
: '1px'};
--size: ${({ checkboxSize }) =>
checkboxSize === CheckboxSize.Large ? '16px' : '12px'};
height: var(--size);
@ -97,6 +111,7 @@ const StyledInput = styled.input<{
export function Checkbox({
checked,
onChange,
onCheckedChange,
indeterminate,
variant = CheckboxVariant.Primary,
size = CheckboxSize.Small,
@ -108,9 +123,11 @@ export function Checkbox({
React.useEffect(() => {
setIsInternalChecked(checked);
}, [checked]);
function handleChange(value: boolean) {
onChange?.(value);
setIsInternalChecked(!isInternalChecked);
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
onChange?.(event);
onCheckedChange?.(event.target.checked);
setIsInternalChecked(event.target.checked);
}
return (
@ -126,7 +143,7 @@ export function Checkbox({
checkboxSize={size}
shape={shape}
isChecked={isInternalChecked}
onChange={(event) => handleChange(event.target.checked)}
onChange={handleChange}
/>
<label htmlFor="checkbox">
{indeterminate ? (

View File

@ -0,0 +1,157 @@
import * as React from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { rgba } from '@/ui/theme/constants/colors';
import { RadioGroup } from './RadioGroup';
export enum RadioSize {
Large = 'large',
Small = 'small',
}
export enum LabelPosition {
Left = 'left',
Right = 'right',
}
const Container = styled.div<{ labelPosition?: LabelPosition }>`
${({ labelPosition }) =>
labelPosition === LabelPosition.Left
? `
flex-direction: row-reverse;
`
: `
flex-direction: row;
`};
align-items: center;
display: flex;
`;
type RadioInputProps = {
radioSize?: RadioSize;
};
const RadioInput = styled(motion.input)<RadioInputProps>`
-webkit-appearance: none;
appearance: none;
background-color: transparent;
border: 1px solid ${({ theme }) => theme.font.color.secondary};
border-radius: 50%;
:hover {
background-color: ${({ theme, checked }) => {
if (!checked) {
return theme.background.tertiary;
}
}};
outline: 4px solid
${({ theme, checked }) => {
if (!checked) {
return theme.background.tertiary;
}
return rgba(theme.color.blue, 0.12);
}};
}
&:checked {
background-color: ${({ theme }) => theme.color.blue};
border: none;
&::after {
background-color: ${({ theme }) => theme.color.gray0};
border-radius: 50%;
content: '';
height: ${({ radioSize }) =>
radioSize === RadioSize.Large ? '8px' : '6px'};
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: ${({ radioSize }) =>
radioSize === RadioSize.Large ? '8px' : '6px'};
}
}
&:disabled {
cursor: not-allowed;
opacity: 0.12;
}
height: ${({ radioSize }) =>
radioSize === RadioSize.Large ? '18px' : '16px'};
position: relative;
width: ${({ radioSize }) =>
radioSize === RadioSize.Large ? '18px' : '16px'};
`;
type LabelProps = {
disabled?: boolean;
labelPosition?: LabelPosition;
};
const Label = styled.label<LabelProps>`
color: ${({ theme }) => theme.font.color.primary};
cursor: pointer;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin-left: ${({ theme, labelPosition }) =>
labelPosition === LabelPosition.Right ? theme.spacing(2) : '0px'};
margin-right: ${({ theme, labelPosition }) =>
labelPosition === LabelPosition.Left ? theme.spacing(2) : '0px'};
opacity: ${({ disabled }) => (disabled ? 0.32 : 1)};
`;
export type RadioProps = {
style?: React.CSSProperties;
className?: string;
checked?: boolean;
value?: string;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
onCheckedChange?: (checked: boolean) => void;
size?: RadioSize;
disabled?: boolean;
labelPosition?: LabelPosition;
};
export function Radio({
checked,
value,
onChange,
onCheckedChange,
size = RadioSize.Small,
labelPosition = LabelPosition.Right,
disabled = false,
...restProps
}: RadioProps) {
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
onChange?.(event);
onCheckedChange?.(event.target.checked);
}
return (
<Container {...restProps} labelPosition={labelPosition}>
<RadioInput
type="radio"
id="input-radio"
name="input-radio"
data-testid="input-radio"
checked={checked}
value={value}
radioSize={size}
disabled={disabled}
onChange={handleChange}
initial={{ scale: 0.95 }}
animate={{ scale: checked ? 1.05 : 0.95 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
/>
{value && (
<Label
htmlFor="input-radio"
labelPosition={labelPosition}
disabled={disabled}
>
{value}
</Label>
)}
</Container>
);
}
Radio.Group = RadioGroup;

View File

@ -0,0 +1,39 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import { RadioProps } from './Radio';
type RadioGroupProps = React.PropsWithChildren & {
value?: string;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
onValueChange?: (value: string) => void;
};
export function RadioGroup({
value,
onChange,
onValueChange,
children,
}: RadioGroupProps) {
const theme = useTheme();
function handleChange(event: React.ChangeEvent<HTMLInputElement>) {
onChange?.(event);
onValueChange?.(event.target.value);
}
return (
<>
{React.Children.map(children, (child) => {
if (React.isValidElement<RadioProps>(child)) {
return React.cloneElement(child, {
style: { marginBottom: theme.spacing(2) },
checked: child.props.value === value,
onChange: handleChange,
});
}
return child;
})}
</>
);
}

View File

@ -0,0 +1,63 @@
import type { Meta, StoryObj } from '@storybook/react';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { LabelPosition, Radio, RadioSize } from '../Radio';
const meta: Meta<typeof Radio> = {
title: 'UI/Input/Radio',
component: Radio,
};
export default meta;
type Story = StoryObj<typeof Radio>;
export const Default: Story = {
args: {
value: 'Radio',
checked: false,
disabled: false,
size: RadioSize.Small,
},
decorators: [ComponentDecorator],
};
export const Catalog: Story = {
args: {
value: 'Radio',
},
argTypes: {
value: { control: false },
size: { control: false },
},
parameters: {
catalog: {
dimensions: [
{
name: 'checked',
values: [false, true],
props: (checked: boolean) => ({ checked }),
},
{
name: 'disabled',
values: [false, true],
props: (disabled: boolean) => ({ disabled }),
},
{
name: 'size',
values: Object.values(RadioSize),
props: (size: RadioSize) => ({ size }),
},
{
name: 'labelPosition',
values: Object.values(LabelPosition),
props: (labelPosition: string) => ({
labelPosition,
}),
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -1,6 +1,8 @@
import {
ChangeEvent,
FocusEventHandler,
ForwardedRef,
forwardRef,
InputHTMLAttributes,
useRef,
useState,
@ -13,6 +15,7 @@ import { IconAlertCircle } from '@/ui/icon';
import { IconEye, IconEyeOff } from '@/ui/icon/index';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useCombinedRefs } from '~/hooks/useCombinedRefs';
import { InputHotkeyScope } from '../types/InputHotkeyScope';
@ -95,22 +98,26 @@ const StyledTrailingIcon = styled.div`
const INPUT_TYPE_PASSWORD = 'password';
export function TextInput({
label,
value,
onChange,
onFocus,
onBlur,
fullWidth,
error,
required,
type,
disableHotkeys = false,
...props
}: OwnProps): JSX.Element {
function TextInputComponent(
{
label,
value,
onChange,
onFocus,
onBlur,
fullWidth,
error,
required,
type,
disableHotkeys = false,
...props
}: OwnProps,
ref: ForwardedRef<HTMLInputElement>,
): JSX.Element {
const theme = useTheme();
const inputRef = useRef<HTMLInputElement>(null);
const combinedRef = useCombinedRefs(ref, inputRef);
const {
goBackToPreviousHotkeyScope,
@ -151,7 +158,7 @@ export function TextInput({
<StyledInputContainer>
<StyledInput
autoComplete="off"
ref={inputRef}
ref={combinedRef}
tabIndex={props.tabIndex ?? 0}
onFocus={handleFocus}
onBlur={handleBlur}
@ -189,3 +196,5 @@ export function TextInput({
</StyledContainer>
);
}
export const TextInput = forwardRef(TextInputComponent);

View File

@ -7,20 +7,42 @@ import {
useListenClickOutside,
} from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
const StyledContainer = styled.div`
align-items: center;
const ModalDiv = styled(motion.div)`
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(10)};
width: calc(400px - ${({ theme }) => theme.spacing(10 * 2)});
`;
const ModalDiv = styled(motion.div)`
background: ${({ theme }) => theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.md};
overflow: hidden;
max-height: 90vh;
z-index: 10000; // should be higher than Backdrop's z-index
`;
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-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 BackDrop = styled(motion.div)`
align-items: center;
background: ${({ theme }) => theme.background.overlay};
@ -34,7 +56,31 @@ const BackDrop = styled(motion.div)`
z-index: 9999;
`;
type Props = React.PropsWithChildren &
/**
* Modal components
*/
type ModalHeaderProps = React.PropsWithChildren & React.ComponentProps<'div'>;
function ModalHeader({ children, ...restProps }: ModalHeaderProps) {
return <StyledHeader {...restProps}>{children}</StyledHeader>;
}
type ModalContentProps = React.PropsWithChildren & React.ComponentProps<'div'>;
function ModalContent({ children, ...restProps }: ModalContentProps) {
return <StyledContent {...restProps}>{children}</StyledContent>;
}
type ModalFooterProps = React.PropsWithChildren & React.ComponentProps<'div'>;
function ModalFooter({ children, ...restProps }: ModalFooterProps) {
return <StyledFooter {...restProps}>{children}</StyledFooter>;
}
/**
* Modal
*/
type ModalProps = React.PropsWithChildren &
React.ComponentProps<'div'> & {
isOpen?: boolean;
onOutsideClick?: () => void;
@ -51,8 +97,8 @@ export function Modal({
children,
onOutsideClick,
...restProps
}: Props) {
const modalRef = useRef(null);
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
useListenClickOutside({
refs: [modalRef],
@ -67,15 +113,23 @@ export function Modal({
return (
<BackDrop>
<ModalDiv
layout
// framer-motion seems to have typing problems with refs
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
ref={modalRef}
initial="hidden"
animate="visible"
exit="exit"
layout
variants={modalVariants}
ref={modalRef}
{...restProps}
>
<StyledContainer {...restProps}>{children}</StyledContainer>
{children}
</ModalDiv>
</BackDrop>
);
}
Modal.Header = ModalHeader;
Modal.Content = ModalContent;
Modal.Footer = ModalFooter;

View File

@ -22,7 +22,11 @@ const StyledContentContainer = styled.div`
const args = {
isOpen: true,
children: <StyledContentContainer>Lorem ipsum</StyledContentContainer>,
children: (
<Modal.Content>
<StyledContentContainer>Lorem ipsum</StyledContentContainer>
</Modal.Content>
),
};
export const Default: Story = {

View File

@ -0,0 +1,71 @@
import React, { useEffect, useMemo } from 'react';
import { motion, useAnimation } from 'framer-motion';
interface Props {
size?: number;
barWidth?: number;
barColor?: string;
}
export function CircularProgressBar({
size = 50,
barWidth = 5,
barColor = 'currentColor',
}: Props) {
const controls = useAnimation();
const circumference = useMemo(
() => 2 * Math.PI * (size / 2 - barWidth),
[size, barWidth],
);
useEffect(() => {
async function animateIndeterminate() {
const baseSegment = Math.max(5, circumference / 10); // Adjusting for smaller values
// Adjusted sequence based on baseSegment
const dashSequences = [
`${baseSegment} ${circumference - baseSegment}`,
`${baseSegment * 2} ${circumference - baseSegment * 2}`,
`${baseSegment * 3} ${circumference - baseSegment * 3}`,
`${baseSegment * 2} ${circumference - baseSegment * 2}`,
`${baseSegment} ${circumference - baseSegment}`,
];
await controls.start({
strokeDasharray: dashSequences,
rotate: [0, 720],
transition: {
strokeDasharray: {
duration: 2,
ease: 'linear',
repeat: Infinity,
repeatType: 'loop',
},
rotate: {
duration: 2,
ease: 'linear',
repeat: Infinity,
repeatType: 'loop',
},
},
});
}
animateIndeterminate();
}, [circumference, controls]);
return (
<motion.svg width={size} height={size} animate={controls}>
<motion.circle
cx={size / 2}
cy={size / 2}
r={size / 2 - barWidth}
fill="none"
stroke={barColor}
strokeWidth={barWidth}
strokeLinecap="round"
/>
</motion.svg>
);
}

View File

@ -0,0 +1,62 @@
import { Meta, StoryObj } from '@storybook/react';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CircularProgressBar } from '../CircularProgressBar';
const meta: Meta<typeof CircularProgressBar> = {
title: 'UI/CircularProgressBar/CircularProgressBar',
component: CircularProgressBar,
args: {
size: 50,
},
};
export default meta;
type Story = StoryObj<typeof CircularProgressBar>;
const args = {};
const defaultArgTypes = {
control: false,
};
export const Default: Story = {
args,
decorators: [ComponentDecorator],
};
export const Catalog = {
args: {
...args,
},
argTypes: {
strokeWidth: defaultArgTypes,
segmentColor: defaultArgTypes,
},
parameters: {
catalog: {
dimensions: [
{
name: 'barColor',
values: [undefined, 'red'],
props: (barColor: string) => ({ barColor }),
labels: (color: string) => `Segment Color: ${color ?? 'default'}`,
},
{
name: 'barWidth',
values: [undefined, 5, 10],
props: (barWidth: number) => ({ barWidth }),
labels: (width: number) =>
`Stroke Width: ${width ? width + ' px' : 'default'}`,
},
{
name: 'size',
values: [undefined, 80, 30],
props: (size: number) => ({ size }),
labels: (size: number) => `Size: ${size ? size + ' px' : 'default'}`,
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -0,0 +1,117 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { AnimatedCheckmark } from '@/ui/checkmark/components/AnimatedCheckmark';
const Container = styled.div<{ isLast: boolean }>`
align-items: center;
display: flex;
flex-grow: ${({ isLast }) => (isLast ? '0' : '1')};
`;
const StepCircle = styled(motion.div)<{ isCurrent: boolean }>`
align-items: center;
border-radius: 50%;
border-style: solid;
border-width: 1px;
display: flex;
flex-basis: auto;
flex-shrink: 0;
height: 20px;
justify-content: center;
overflow: hidden;
position: relative;
width: 20px;
`;
const StepIndex = styled.span`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
`;
const StepLabel = styled.span<{ isActive: boolean }>`
color: ${({ theme, isActive }) =>
isActive ? theme.font.color.primary : theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
margin-left: ${({ theme }) => theme.spacing(2)};
white-space: nowrap;
`;
const StepLine = styled(motion.div)<{ isActive: boolean }>`
height: 2px;
margin-left: ${({ theme }) => theme.spacing(2)};
margin-right: ${({ theme }) => theme.spacing(2)};
overflow: hidden;
width: 100%;
`;
export type StepProps = React.PropsWithChildren &
React.ComponentProps<'div'> & {
isActive?: boolean;
isLast?: boolean;
index?: number;
label: string;
};
export const Step = ({
isActive = false,
isLast = false,
index = 0,
label,
children,
}: StepProps) => {
const theme = useTheme();
const variantsCircle = {
active: {
backgroundColor: theme.font.color.primary,
borderColor: theme.font.color.primary,
transition: { duration: 0.5 },
},
inactive: {
backgroundColor: theme.background.transparent.lighter,
borderColor: theme.border.color.medium,
transition: { duration: 0.5 },
},
};
const variantsLine = {
active: {
backgroundColor: theme.font.color.primary,
transition: { duration: 0.5 },
},
inactive: {
backgroundColor: theme.border.color.medium,
transition: { duration: 0.5 },
},
};
return (
<Container isLast={isLast}>
<StepCircle
isCurrent={isActive}
variants={variantsCircle}
animate={isActive ? 'active' : 'inactive'}
>
{isActive && (
<AnimatedCheckmark isAnimating={isActive} color={theme.color.gray0} />
)}
{!isActive && <StepIndex>{index + 1}</StepIndex>}
</StepCircle>
<StepLabel isActive={isActive}>{label}</StepLabel>
{!isLast && (
<StepLine
isActive={isActive}
variants={variantsLine}
animate={isActive ? 'active' : 'inactive'}
/>
)}
{isActive && children}
</Container>
);
};
Step.displayName = 'StepBar/Step';

View File

@ -0,0 +1,42 @@
import React from 'react';
import styled from '@emotion/styled';
import { Step, StepProps } from './Step';
const Container = styled.div`
display: flex;
flex: 1;
justify-content: space-between;
`;
export type StepsProps = React.PropsWithChildren &
React.ComponentProps<'div'> & {
activeStep: number;
};
export const StepBar = ({ children, activeStep, ...restProps }: StepsProps) => {
return (
<Container {...restProps}>
{React.Children.map(children, (child, index) => {
if (!React.isValidElement(child)) {
return null;
}
// If the child is not a Step, return it as-is
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
if (child.type?.displayName !== Step.displayName) {
return child;
}
return React.cloneElement<StepProps>(child as any, {
index,
isActive: index <= activeStep,
isLast: index === React.Children.count(children) - 1,
});
})}
</Container>
);
};
StepBar.Step = Step;

View File

@ -0,0 +1,59 @@
import { useCallback, useEffect } from 'react';
import { useRecoilState } from 'recoil';
import { stepBarInternalState } from '../states/stepBarInternalState';
export type StepsOptions = {
initialStep: number;
};
export function useStepBar({ initialStep }: StepsOptions) {
const [stepsState, setStepsState] = useRecoilState(stepBarInternalState);
function nextStep() {
setStepsState((prevState) => ({
...prevState,
activeStep: prevState.activeStep + 1,
}));
}
function prevStep() {
setStepsState((prevState) => ({
...prevState,
activeStep: prevState.activeStep - 1,
}));
}
function reset() {
setStepsState((prevState) => ({
...prevState,
activeStep: 0,
}));
}
const setStep = useCallback(
(step: number) => {
setStepsState((prevState) => ({
...prevState,
activeStep: step,
}));
},
[setStepsState],
);
useEffect(() => {
if (initialStep !== undefined) {
setStep(initialStep);
}
// We only want this to happen on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
nextStep,
prevStep,
reset,
setStep,
activeStep: stepsState.activeStep,
};
}

View File

@ -0,0 +1,12 @@
import { atom } from 'recoil';
export type StepsState = {
activeStep: number;
};
export const stepBarInternalState = atom<StepsState>({
key: 'step-bar/internal-state',
default: {
activeStep: -1,
},
});

View File

@ -9,6 +9,7 @@ import { useTheme } from '@emotion/react';
import { useRecoilState, useRecoilValue } from 'recoil';
import { v4 } from 'uuid';
import { useSpreadsheetImport } from '@/spreadsheet-import/hooks/useSpreadsheetImport';
import { IconButton } from '@/ui/button/components/IconButton';
import { DropdownMenuHeader } from '@/ui/dropdown/components/DropdownMenuHeader';
import { DropdownMenuInput } from '@/ui/dropdown/components/DropdownMenuInput';
@ -20,7 +21,13 @@ import type {
ViewFieldMetadata,
} from '@/ui/editable-field/types/ViewField';
import DropdownButton from '@/ui/filter-n-sort/components/DropdownButton';
import { IconChevronLeft, IconMinus, IconPlus, IconTag } from '@/ui/icon';
import {
IconChevronLeft,
IconFileImport,
IconMinus,
IconPlus,
IconTag,
} from '@/ui/icon';
import {
hiddenTableColumnsState,
tableColumnsState,
@ -58,6 +65,8 @@ export const TableOptionsDropdownButton = ({
}: TableOptionsDropdownButtonProps) => {
const theme = useTheme();
const { openSpreadsheetImport } = useSpreadsheetImport();
const [isUnfolded, setIsUnfolded] = useState(false);
const [selectedOption, setSelectedOption] = useState<Option | undefined>(
undefined,
@ -85,6 +94,16 @@ export const TableOptionsDropdownButton = ({
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
function handleImport() {
openSpreadsheetImport({
onSubmit: (datam, file) => {
console.log('datam', datam);
console.log('file', file);
},
fields: [],
});
}
const handleColumnVisibilityChange = useCallback(
(columnId: string, nextIsVisible: boolean) => {
const nextColumns = columns.map((column) =>
@ -226,6 +245,12 @@ export const TableOptionsDropdownButton = ({
<IconTag size={theme.icon.size.md} />
Properties
</DropdownMenuItem>
{false && (
<DropdownMenuItem onClick={handleImport}>
<IconFileImport size={theme.icon.size.md} />
Import
</DropdownMenuItem>
)}
</DropdownMenuItemsContainer>
</>
)}

View File

@ -1,7 +1,7 @@
import DarkNoise from '../assets/dark-noise.jpg';
import LightNoise from '../assets/light-noise.png';
import { grayScale, rgba } from './colors';
import { color, grayScale, rgba } from './colors';
export const backgroundLight = {
noisy: `url(${LightNoise.toString()});`,
@ -16,6 +16,7 @@ export const backgroundLight = {
medium: rgba(grayScale.gray100, 0.08),
light: rgba(grayScale.gray100, 0.04),
lighter: rgba(grayScale.gray100, 0.02),
danger: rgba(color.red, 0.08),
},
overlay: rgba(grayScale.gray80, 0.8),
};
@ -33,6 +34,7 @@ export const backgroundDark = {
medium: rgba(grayScale.gray0, 0.1),
light: rgba(grayScale.gray0, 0.06),
lighter: rgba(grayScale.gray0, 0.03),
danger: rgba(color.red, 0.08),
},
overlay: rgba(grayScale.gray80, 0.8),
};

View File

@ -1,4 +1,4 @@
import { grayScale } from './colors';
import { color, grayScale, rgba } from './colors';
const common = {
radius: {
@ -16,6 +16,7 @@ export const borderLight = {
light: grayScale.gray15,
secondaryInverted: grayScale.gray50,
inverted: grayScale.gray60,
danger: rgba(color.red, 0.14),
},
...common,
};
@ -27,6 +28,7 @@ export const borderDark = {
light: grayScale.gray70,
secondaryInverted: grayScale.gray35,
inverted: grayScale.gray20,
danger: rgba(color.red, 0.14),
},
...common,
};

View File

@ -22,7 +22,7 @@ export const grayScale = {
gray0: '#ffffff',
};
export const color: { [key: string]: string } = {
export const color = {
yellow: '#ffd338',
yellow80: '#2e2a1a',
yellow70: '#453d1e',

View File

@ -1,6 +1,8 @@
import { Tooltip } from 'react-tooltip';
import styled from '@emotion/styled';
import { rgba } from '../theme/constants/colors';
export enum TooltipPosition {
Top = 'top',
Left = 'left',
@ -9,16 +11,21 @@ export enum TooltipPosition {
}
export const AppTooltip = styled(Tooltip)`
background-color: ${({ theme }) => theme.background.primary};
box-shadow: ${({ theme }) => theme.boxShadow.light};
backdrop-filter: ${({ theme }) => theme.blur.strong};
background-color: ${({ theme }) => rgba(theme.color.gray80, 0.8)};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.primary};
box-shadow: ${({ theme }) => theme.boxShadow.light};
color: ${({ theme }) => theme.color.gray0};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
max-width: 40%;
overflow: visible;
padding: ${({ theme }) => theme.spacing(2)};
word-break: break-word;
z-index: ${({ theme }) => theme.lastLayerZIndex};