Migrate to a monorepo structure (#2909)

This commit is contained in:
Charles Bochet
2023-12-10 18:10:54 +01:00
committed by GitHub
parent a70a9281eb
commit 5bdca9de6c
2304 changed files with 37152 additions and 25869 deletions

View File

@ -0,0 +1,243 @@
import { useState } from 'react';
import { HotkeysEvent } from 'react-hotkeys-hook/dist/types';
import TextareaAutosize from 'react-textarea-autosize';
import styled from '@emotion/styled';
import { IconArrowRight } from '@/ui/display/icon/index';
import { Button } from '@/ui/input/button/components/Button';
import { RoundedIconButton } from '@/ui/input/button/components/RoundedIconButton';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { InputHotkeyScope } from '../types/InputHotkeyScope';
const MAX_ROWS = 5;
export enum AutosizeTextInputVariant {
Default = 'default',
Icon = 'icon',
Button = 'button',
}
type AutosizeTextInputProps = {
onValidate?: (text: string) => void;
minRows?: number;
placeholder?: string;
onFocus?: () => void;
variant?: AutosizeTextInputVariant;
buttonTitle?: string;
value?: string;
className?: string;
};
const StyledContainer = styled.div`
width: 100%;
`;
const StyledInputContainer = styled.div`
display: flex;
position: relative;
width: 100%;
`;
type StyledTextAreaProps = {
variant: AutosizeTextInputVariant;
};
const StyledTextArea = styled(TextareaAutosize)<StyledTextAreaProps>`
background: ${({ theme, variant }) =>
variant === AutosizeTextInputVariant.Button
? 'transparent'
: theme.background.tertiary};
border: none;
border-radius: 5px;
color: ${({ theme }) => theme.font.color.primary};
font-family: inherit;
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
line-height: 16px;
overflow: auto;
&:focus {
border: none;
outline: none;
}
&::placeholder {
color: ${({ theme }) => theme.font.color.light};
font-weight: ${({ theme }) => theme.font.weight.regular};
}
padding: ${({ variant }) =>
variant === AutosizeTextInputVariant.Button ? '8px 0' : '8px'};
resize: none;
width: 100%;
`;
// TODO: this messes with the layout, fix it
const StyledBottomRightRoundedIconButton = styled.div`
height: 0;
position: relative;
right: 26px;
top: 6px;
width: 0px;
`;
const StyledSendButton = styled(Button)`
margin-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledWordCounter = styled.div`
color: ${({ theme }) => theme.font.color.light};
font-weight: ${({ theme }) => theme.font.weight.medium};
line-height: 150%;
width: 100%;
`;
type StyledBottomContainerProps = {
isTextAreaHidden: boolean;
};
const StyledBottomContainer = styled.div<StyledBottomContainerProps>`
align-items: center;
display: flex;
justify-content: space-between;
margin-top: ${({ theme, isTextAreaHidden }) =>
isTextAreaHidden ? 0 : theme.spacing(4)};
`;
const StyledCommentText = styled.div`
cursor: text;
padding-bottom: ${({ theme }) => theme.spacing(1)};
padding-top: ${({ theme }) => theme.spacing(1)};
`;
export const AutosizeTextInput = ({
placeholder,
onValidate,
minRows = 1,
onFocus,
variant = AutosizeTextInputVariant.Default,
buttonTitle,
value = '',
className,
}: AutosizeTextInputProps) => {
const [isFocused, setIsFocused] = useState(false);
const [isHidden, setIsHidden] = useState(
variant === AutosizeTextInputVariant.Button,
);
const [text, setText] = useState(value);
const isSendButtonDisabled = !text;
const words = text.split(/\s|\n/).filter((word) => word).length;
useScopedHotkeys(
['shift+enter', 'enter'],
(event: KeyboardEvent, handler: HotkeysEvent) => {
if (handler.shift || !isFocused) {
return;
} else {
event.preventDefault();
onValidate?.(text);
setText('');
}
},
InputHotkeyScope.TextInput,
[onValidate, text, setText, isFocused],
{
enableOnContentEditable: true,
enableOnFormTags: true,
},
);
useScopedHotkeys(
'esc',
(event: KeyboardEvent) => {
if (!isFocused) {
return;
}
event.preventDefault();
setText('');
},
InputHotkeyScope.TextInput,
[onValidate, setText, isFocused],
{
enableOnContentEditable: true,
enableOnFormTags: true,
},
);
const handleInputChange = (event: React.FormEvent<HTMLTextAreaElement>) => {
const newText = event.currentTarget.value;
setText(newText);
};
const handleOnClickSendButton = () => {
onValidate?.(text);
setText('');
};
const computedMinRows = minRows > MAX_ROWS ? MAX_ROWS : minRows;
return (
<>
<StyledContainer className={className}>
<StyledInputContainer>
{!isHidden && (
<StyledTextArea
autoFocus={variant === AutosizeTextInputVariant.Button}
placeholder={placeholder ?? 'Write a comment'}
maxRows={MAX_ROWS}
minRows={computedMinRows}
onChange={handleInputChange}
value={text}
onFocus={() => {
onFocus?.();
setIsFocused(true);
}}
onBlur={() => setIsFocused(false)}
variant={variant}
/>
)}
{variant === AutosizeTextInputVariant.Icon && (
<StyledBottomRightRoundedIconButton>
<RoundedIconButton
onClick={handleOnClickSendButton}
Icon={IconArrowRight}
disabled={isSendButtonDisabled}
/>
</StyledBottomRightRoundedIconButton>
)}
</StyledInputContainer>
{variant === AutosizeTextInputVariant.Button && (
<StyledBottomContainer isTextAreaHidden={isHidden}>
<StyledWordCounter>
{isHidden ? (
<StyledCommentText
onClick={() => {
setIsHidden(false);
onFocus?.();
}}
>
Write a comment
</StyledCommentText>
) : (
`${words} word${words === 1 ? '' : 's'}`
)}
</StyledWordCounter>
<StyledSendButton
title={buttonTitle ?? 'Comment'}
disabled={isSendButtonDisabled}
onClick={handleOnClickSendButton}
/>
</StyledBottomContainer>
)}
</StyledContainer>
</>
);
};

View File

@ -0,0 +1,165 @@
import * as React from 'react';
import styled from '@emotion/styled';
import { v4 } from 'uuid';
import { IconCheck, IconMinus } from '@/ui/display/icon';
export enum CheckboxVariant {
Primary = 'primary',
Secondary = 'secondary',
Tertiary = 'tertiary',
}
export enum CheckboxShape {
Squared = 'squared',
Rounded = 'rounded',
}
export enum CheckboxSize {
Large = 'large',
Small = 'small',
}
type CheckboxProps = {
checked: boolean;
indeterminate?: boolean;
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
onCheckedChange?: (value: boolean) => void;
variant?: CheckboxVariant;
size?: CheckboxSize;
shape?: CheckboxShape;
className?: string;
};
const StyledInputContainer = styled.div`
align-items: center;
display: flex;
position: relative;
`;
type InputProps = {
checkboxSize: CheckboxSize;
variant: CheckboxVariant;
indeterminate?: boolean;
shape?: CheckboxShape;
isChecked?: boolean;
};
const StyledInput = styled.input<InputProps>`
cursor: pointer;
margin: 0;
opacity: 0;
position: absolute;
z-index: 10;
& + label {
--size: ${({ checkboxSize }) =>
checkboxSize === CheckboxSize.Large ? '18px' : '12px'};
cursor: pointer;
height: calc(var(--size) + 2px);
padding: 0;
position: relative;
width: calc(var(--size) + 2px);
}
& + label:before {
--size: ${({ checkboxSize }) =>
checkboxSize === CheckboxSize.Large ? '18px' : '12px'};
background: ${({ theme, indeterminate, isChecked }) =>
indeterminate || isChecked ? theme.color.blue : 'transparent'};
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: ${({ variant }) =>
variant === CheckboxVariant.Tertiary ? '2px' : '1px'};
content: '';
cursor: pointer;
display: inline-block;
height: var(--size);
width: var(--size);
}
& + label > svg {
--padding: ${({ checkboxSize, variant }) =>
checkboxSize === CheckboxSize.Large ||
variant === CheckboxVariant.Tertiary
? '2px'
: '1px'};
--size: ${({ checkboxSize }) =>
checkboxSize === CheckboxSize.Large ? '16px' : '12px'};
height: var(--size);
left: var(--padding);
position: absolute;
stroke: ${({ theme }) => theme.grayScale.gray0};
top: var(--padding);
width: var(--size);
}
`;
export const Checkbox = ({
checked,
onChange,
onCheckedChange,
indeterminate,
variant = CheckboxVariant.Primary,
size = CheckboxSize.Small,
shape = CheckboxShape.Squared,
className,
}: CheckboxProps) => {
const [isInternalChecked, setIsInternalChecked] =
React.useState<boolean>(false);
React.useEffect(() => {
setIsInternalChecked(checked);
}, [checked]);
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(event);
onCheckedChange?.(event.target.checked);
setIsInternalChecked(event.target.checked);
};
const checkboxId = 'checkbox' + v4();
return (
<StyledInputContainer className={className}>
<StyledInput
autoComplete="off"
type="checkbox"
id={checkboxId}
name="styled-checkbox"
data-testid="input-checkbox"
checked={isInternalChecked}
indeterminate={indeterminate}
variant={variant}
checkboxSize={size}
shape={shape}
isChecked={isInternalChecked}
onChange={handleChange}
/>
<label htmlFor={checkboxId}>
{indeterminate ? (
<IconMinus />
) : isInternalChecked ? (
<IconCheck />
) : (
<></>
)}
</label>
</StyledInputContainer>
);
};

View File

@ -0,0 +1,90 @@
import { ChangeEvent } from 'react';
import styled from '@emotion/styled';
import { StyledInput } from '@/ui/field/input/components/TextInput';
import { ComputeNodeDimensions } from '@/ui/utilities/dimensions/components/ComputeNodeDimensions';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { InputHotkeyScope } from '../types/InputHotkeyScope';
export type EntityTitleDoubleTextInputProps = {
firstValue: string;
secondValue: string;
firstValuePlaceholder: string;
secondValuePlaceholder: string;
onChange: (firstValue: string, secondValue: string) => void;
className?: string;
};
const StyledDoubleTextContainer = styled.div`
align-items: center;
display: flex;
justify-content: center;
text-align: center;
`;
const StyledTextInput = styled(StyledInput)`
margin: 0 ${({ theme }) => theme.spacing(0.5)};
padding: 0;
width: ${({ width }) => (width ? `${width}px` : 'auto')};
&:hover:not(:focus) {
background-color: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
cursor: pointer;
padding: 0 ${({ theme }) => theme.spacing(1)};
}
`;
export const EntityTitleDoubleTextInput = ({
firstValue,
secondValue,
firstValuePlaceholder,
secondValuePlaceholder,
onChange,
className,
}: EntityTitleDoubleTextInputProps) => {
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const handleFocus = () => {
setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput);
};
const handleBlur = () => {
goBackToPreviousHotkeyScope();
};
return (
<StyledDoubleTextContainer className={className}>
<ComputeNodeDimensions node={firstValue || firstValuePlaceholder}>
{(nodeDimensions) => (
<StyledTextInput
width={nodeDimensions?.width}
placeholder={firstValuePlaceholder}
value={firstValue}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(event.target.value, secondValue);
}}
/>
)}
</ComputeNodeDimensions>
<ComputeNodeDimensions node={secondValue || secondValuePlaceholder}>
{(nodeDimensions) => (
<StyledTextInput
width={nodeDimensions?.width}
autoComplete="off"
placeholder={secondValuePlaceholder}
value={secondValue}
onFocus={handleFocus}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange(firstValue, event.target.value);
}}
/>
)}
</ComputeNodeDimensions>
</StyledDoubleTextContainer>
);
};

View File

@ -0,0 +1,195 @@
import { useMemo, useState } from 'react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { arrayToChunks } from '~/utils/array/array-to-chunks';
import { IconButton, IconButtonVariant } from '../button/components/IconButton';
import { LightIconButton } from '../button/components/LightIconButton';
import { IconApps } from '../constants/icons';
import { useLazyLoadIcons } from '../hooks/useLazyLoadIcons';
import { DropdownMenuSkeletonItem } from '../relation-picker/components/skeletons/DropdownMenuSkeletonItem';
import { IconPickerHotkeyScope } from '../types/IconPickerHotkeyScope';
type IconPickerProps = {
disabled?: boolean;
dropdownScopeId?: string;
onChange: (params: { iconKey: string; Icon: IconComponent }) => void;
selectedIconKey?: string;
onClickOutside?: () => void;
onClose?: () => void;
onOpen?: () => void;
variant?: IconButtonVariant;
className?: string;
};
const StyledMenuIconItemsContainer = styled.div`
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledLightIconButton = styled(LightIconButton)<{ isSelected?: boolean }>`
background: ${({ theme, isSelected }) =>
isSelected ? theme.background.transparent.medium : 'transparent'};
`;
const convertIconKeyToLabel = (iconKey: string) =>
iconKey.replace(/[A-Z]/g, (letter) => ` ${letter}`).trim();
type IconPickerIconProps = {
iconKey: string;
onClick: () => void;
selectedIconKey?: string;
Icon: IconComponent;
};
const IconPickerIcon = ({
iconKey,
onClick,
selectedIconKey,
Icon,
}: IconPickerIconProps) => {
const { isSelectedItemId } = useSelectableList({ itemId: iconKey });
return (
<StyledLightIconButton
key={iconKey}
aria-label={convertIconKeyToLabel(iconKey)}
size="medium"
title={iconKey}
isSelected={iconKey === selectedIconKey || isSelectedItemId}
Icon={Icon}
onClick={onClick}
/>
);
};
export const IconPicker = ({
disabled,
dropdownScopeId = 'icon-picker',
onChange,
selectedIconKey,
onClickOutside,
onClose,
onOpen,
variant = 'secondary',
className,
}: IconPickerProps) => {
const [searchString, setSearchString] = useState('');
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const { closeDropdown } = useDropdown({ dropdownScopeId });
const { icons, isLoadingIcons: isLoading } = useLazyLoadIcons();
const iconKeys = useMemo(() => {
const filteredIconKeys = Object.keys(icons).filter((iconKey) => {
return (
iconKey !== selectedIconKey &&
(!searchString ||
[iconKey, convertIconKeyToLabel(iconKey)].some((label) =>
label.toLowerCase().includes(searchString.toLowerCase()),
))
);
});
return (
selectedIconKey
? [selectedIconKey, ...filteredIconKeys]
: filteredIconKeys
).slice(0, 25);
}, [icons, searchString, selectedIconKey]);
const iconKeys2d = useMemo(
() => arrayToChunks(iconKeys.slice(), 5),
[iconKeys],
);
return (
<DropdownScope dropdownScopeId={dropdownScopeId}>
<div className={className}>
<Dropdown
dropdownHotkeyScope={{ scope: IconPickerHotkeyScope.IconPicker }}
clickableComponent={
<IconButton
disabled={disabled}
Icon={selectedIconKey ? icons[selectedIconKey] : IconApps}
variant={variant}
/>
}
dropdownMenuWidth={176}
dropdownComponents={
<SelectableList
selectableListId="icon-list"
selectableItemIds={iconKeys2d}
hotkeyScope={IconPickerHotkeyScope.IconPicker}
onEnter={(iconKey) => {
onChange({ iconKey, Icon: icons[iconKey] });
closeDropdown();
}}
>
<DropdownMenu width={176}>
<DropdownMenuSearchInput
placeholder="Search icon"
autoFocus
onChange={(event) => setSearchString(event.target.value)}
/>
<DropdownMenuSeparator />
<div
onMouseEnter={() => {
setHotkeyScopeAndMemorizePreviousScope(
IconPickerHotkeyScope.IconPicker,
);
}}
onMouseLeave={goBackToPreviousHotkeyScope}
>
<DropdownMenuItemsContainer>
{isLoading ? (
<DropdownMenuSkeletonItem />
) : (
<StyledMenuIconItemsContainer>
{iconKeys.map((iconKey) => (
<IconPickerIcon
key={iconKey}
iconKey={iconKey}
onClick={() => {
onChange({ iconKey, Icon: icons[iconKey] });
closeDropdown();
}}
selectedIconKey={selectedIconKey}
Icon={icons[iconKey]}
/>
))}
</StyledMenuIconItemsContainer>
)}
</DropdownMenuItemsContainer>
</div>
</DropdownMenu>
</SelectableList>
}
onClickOutside={onClickOutside}
onClose={() => {
onClose?.();
setSearchString('');
}}
onOpen={onOpen}
/>
</div>
</DropdownScope>
);
};

View File

@ -0,0 +1,177 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
IconFileUpload,
IconTrash,
IconUpload,
IconX,
} from '@/ui/display/icon';
import { Button } from '@/ui/input/button/components/Button';
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
`;
const StyledPicture = styled.button<{ withPicture: boolean }>`
align-items: center;
background: ${({ theme, disabled }) =>
disabled ? theme.background.secondary : theme.background.tertiary};
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.light};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
height: 66px;
justify-content: center;
overflow: hidden;
padding: 0;
transition: background 0.1s ease;
width: 66px;
img {
height: 100%;
object-fit: cover;
width: 100%;
}
${({ theme, withPicture, disabled }) => {
if (withPicture || disabled) {
return '';
}
return `
&:hover {
background: ${theme.background.quaternary};
}
`;
}};
`;
const StyledContent = styled.div`
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
margin-left: ${({ theme }) => theme.spacing(4)};
`;
const StyledButtonContainer = styled.div`
display: flex;
flex-direction: row;
> * + * {
margin-left: ${({ theme }) => theme.spacing(2)};
}
`;
const StyledText = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
`;
const StyledErrorText = styled.span`
color: ${({ theme }) => theme.font.color.danger};
font-size: ${({ theme }) => theme.font.size.xs};
margin-top: ${({ theme }) => theme.spacing(1)};
`;
const StyledHiddenFileInput = styled.input`
display: none;
`;
type ImageInputProps = Omit<React.ComponentProps<'div'>, 'children'> & {
picture: string | null | undefined;
onUpload?: (file: File) => void;
onRemove?: () => void;
onAbort?: () => void;
isUploading?: boolean;
errorMessage?: string | null;
disabled?: boolean;
className?: string;
};
export const ImageInput = ({
picture,
onUpload,
onRemove,
onAbort,
isUploading = false,
errorMessage,
disabled = false,
className,
}: ImageInputProps) => {
const theme = useTheme();
const hiddenFileInput = React.useRef<HTMLInputElement>(null);
const onUploadButtonClick = () => {
hiddenFileInput.current?.click();
};
return (
<StyledContainer className={className}>
<StyledPicture
withPicture={!!picture}
disabled={disabled}
onClick={onUploadButtonClick}
>
{picture ? (
<img
src={picture || '/images/default-profile-picture.png'}
alt="profile"
/>
) : (
<IconFileUpload size={theme.icon.size.md} />
)}
</StyledPicture>
<StyledContent>
<StyledButtonContainer>
<StyledHiddenFileInput
type="file"
ref={hiddenFileInput}
accept="image/jpeg, image/png, image/gif" // to desired specification
onChange={(event) => {
if (onUpload) {
if (event.target.files) {
onUpload(event.target.files[0]);
}
}
}}
/>
{isUploading && onAbort ? (
<Button
Icon={IconX}
onClick={onAbort}
variant="secondary"
title="Abort"
disabled={!picture || disabled}
fullWidth
/>
) : (
<Button
Icon={IconUpload}
onClick={onUploadButtonClick}
variant="secondary"
title="Upload"
disabled={disabled}
fullWidth
/>
)}
<Button
Icon={IconTrash}
onClick={onRemove}
variant="secondary"
title="Remove"
disabled={!picture || disabled}
fullWidth
/>
</StyledButtonContainer>
<StyledText>
We support your best PNGs, JPEGs and GIFs portraits under 10MB
</StyledText>
{errorMessage && <StyledErrorText>{errorMessage}</StyledErrorText>}
</StyledContent>
</StyledContainer>
);
};

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 StyledContainer = styled.div<{ labelPosition?: LabelPosition }>`
${({ labelPosition }) =>
labelPosition === LabelPosition.Left
? `
flex-direction: row-reverse;
`
: `
flex-direction: row;
`};
align-items: center;
display: inline-flex;
`;
type RadioInputProps = {
'radio-size'?: RadioSize;
};
const StyledRadioInput = 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.grayScale.gray0};
border-radius: 50%;
content: '';
height: ${({ 'radio-size': radioSize }) =>
radioSize === RadioSize.Large ? '8px' : '6px'};
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: ${({ 'radio-size': radioSize }) =>
radioSize === RadioSize.Large ? '8px' : '6px'};
}
}
&:disabled {
cursor: not-allowed;
opacity: 0.12;
}
height: ${({ 'radio-size': radioSize }) =>
radioSize === RadioSize.Large ? '18px' : '16px'};
position: relative;
width: ${({ 'radio-size': radioSize }) =>
radioSize === RadioSize.Large ? '18px' : '16px'};
`;
type LabelProps = {
disabled?: boolean;
labelPosition?: LabelPosition;
};
const StyledLabel = 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 const Radio = ({
checked,
value,
onChange,
onCheckedChange,
size = RadioSize.Small,
labelPosition = LabelPosition.Right,
disabled = false,
className,
}: RadioProps) => {
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(event);
onCheckedChange?.(event.target.checked);
};
return (
<StyledContainer className={className} labelPosition={labelPosition}>
<StyledRadioInput
type="radio"
id="input-radio"
name="input-radio"
data-testid="input-radio"
checked={checked}
value={value}
radio-size={size}
disabled={disabled}
onChange={handleChange}
initial={{ scale: 0.95 }}
animate={{ scale: checked ? 1.05 : 0.95 }}
transition={{ type: 'spring', stiffness: 300, damping: 20 }}
/>
{value && (
<StyledLabel
htmlFor="input-radio"
labelPosition={labelPosition}
disabled={disabled}
>
{value}
</StyledLabel>
)}
</StyledContainer>
);
};
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 const RadioGroup = ({
value,
onChange,
onValueChange,
children,
}: RadioGroupProps) => {
const theme = useTheme();
const 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,120 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconChevronDown } from '@/ui/display/icon';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { SelectHotkeyScope } from '../types/SelectHotkeyScope';
export type SelectProps<Value extends string | number | null> = {
className?: string;
disabled?: boolean;
dropdownScopeId: string;
label?: string;
onChange?: (value: Value) => void;
options: { value: Value; label: string; Icon?: IconComponent }[];
value?: Value;
};
const StyledControlContainer = styled.div<{ disabled?: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ disabled, theme }) =>
disabled ? theme.font.color.tertiary : theme.font.color.primary};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: inline-flex;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme }) => theme.spacing(8)};
justify-content: space-between;
padding: 0 ${({ theme }) => theme.spacing(2)};
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
display: block;
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-transform: uppercase;
`;
const StyledControlLabel = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledIconChevronDown = styled(IconChevronDown)<{ disabled?: boolean }>`
color: ${({ disabled, theme }) =>
disabled ? theme.font.color.extraLight : theme.font.color.tertiary};
`;
export const Select = <Value extends string | number | null>({
className,
disabled,
dropdownScopeId,
label,
onChange,
options,
value,
}: SelectProps<Value>) => {
const theme = useTheme();
const selectedOption =
options.find(({ value: key }) => key === value) || options[0];
const { closeDropdown } = useDropdown({ dropdownScopeId });
const selectControl = (
<StyledControlContainer disabled={disabled}>
<StyledControlLabel>
{!!selectedOption?.Icon && (
<selectedOption.Icon
color={disabled ? theme.font.color.light : theme.font.color.primary}
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
/>
)}
{selectedOption?.label}
</StyledControlLabel>
<StyledIconChevronDown disabled={disabled} size={theme.icon.size.md} />
</StyledControlContainer>
);
return disabled ? (
selectControl
) : (
<DropdownScope dropdownScopeId={dropdownScopeId}>
<div className={className}>
{!!label && <StyledLabel>{label}</StyledLabel>}
<Dropdown
dropdownMenuWidth={176}
dropdownPlacement="bottom-start"
clickableComponent={selectControl}
dropdownComponents={
<DropdownMenuItemsContainer>
{options.map((option) => (
<MenuItem
key={option.value}
LeftIcon={option.Icon}
text={option.label}
onClick={() => {
onChange?.(option.value);
closeDropdown();
}}
/>
))}
</DropdownMenuItemsContainer>
}
dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }}
/>
</div>
</DropdownScope>
);
};

View File

@ -0,0 +1,86 @@
import { FocusEventHandler } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import styled from '@emotion/styled';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { InputHotkeyScope } from '../types/InputHotkeyScope';
const MAX_ROWS = 5;
export type TextAreaProps = {
disabled?: boolean;
minRows?: number;
onChange?: (value: string) => void;
placeholder?: string;
value?: string;
className?: string;
};
const StyledTextArea = styled(TextareaAutosize)`
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
color: ${({ theme }) => theme.font.color.primary};
font-family: inherit;
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
line-height: 16px;
overflow: auto;
padding: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(3)};
resize: none;
width: 100%;
&:focus {
outline: none;
}
&::placeholder {
color: ${({ theme }) => theme.font.color.light};
font-weight: ${({ theme }) => theme.font.weight.regular};
}
&:disabled {
color: ${({ theme }) => theme.font.color.tertiary};
}
`;
export const TextArea = ({
disabled,
placeholder,
minRows = 1,
value = '',
className,
onChange,
}: TextAreaProps) => {
const computedMinRows = Math.min(minRows, MAX_ROWS);
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const handleFocus: FocusEventHandler<HTMLTextAreaElement> = () => {
setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput);
};
const handleBlur: FocusEventHandler<HTMLTextAreaElement> = () => {
goBackToPreviousHotkeyScope();
};
return (
<StyledTextArea
placeholder={placeholder}
maxRows={MAX_ROWS}
minRows={computedMinRows}
value={value}
onChange={(event) => onChange?.(event.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
disabled={disabled}
className={className}
/>
);
};

View File

@ -0,0 +1,222 @@
import {
ChangeEvent,
FocusEventHandler,
ForwardedRef,
forwardRef,
InputHTMLAttributes,
useRef,
useState,
} from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { IconAlertCircle } from '@/ui/display/icon';
import { IconEye, IconEyeOff } from '@/ui/display/icon/index';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
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';
export type TextInputComponentProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'onChange' | 'onKeyDown'
> & {
className?: string;
label?: string;
onChange?: (text: string) => void;
fullWidth?: boolean;
disableHotkeys?: boolean;
error?: string;
RightIcon?: IconComponent;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
};
const StyledContainer = styled.div<Pick<TextInputComponentProps, 'fullWidth'>>`
display: inline-flex;
flex-direction: column;
width: ${({ fullWidth }) => (fullWidth ? `100%` : 'auto')};
`;
const StyledLabel = styled.span`
color: ${({ theme }) => theme.font.color.light};
font-size: ${({ theme }) => theme.font.size.xs};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin-bottom: ${({ theme }) => theme.spacing(1)};
text-transform: uppercase;
`;
const StyledInputContainer = styled.div`
display: flex;
flex-direction: row;
width: 100%;
`;
const StyledInput = styled.input<Pick<TextInputComponentProps, 'fullWidth'>>`
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-bottom-left-radius: ${({ theme }) => theme.border.radius.sm};
border-right: none;
border-top-left-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.font.color.primary};
display: flex;
flex-grow: 1;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
outline: none;
padding: ${({ theme }) => theme.spacing(2)};
width: 100%;
&::placeholder,
&::-webkit-input-placeholder {
color: ${({ theme }) => theme.font.color.light};
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.medium};
}
&:disabled {
color: ${({ theme }) => theme.font.color.tertiary};
}
`;
const StyledErrorHelper = styled.div`
color: ${({ theme }) => theme.color.red};
font-size: ${({ theme }) => theme.font.size.xs};
padding: ${({ theme }) => theme.spacing(1)};
`;
const StyledTrailingIconContainer = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-bottom-right-radius: ${({ theme }) => theme.border.radius.sm};
border-left: none;
border-top-right-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
justify-content: center;
padding-right: ${({ theme }) => theme.spacing(1)};
`;
const StyledTrailingIcon = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.light};
cursor: ${({ onClick }) => (onClick ? 'pointer' : 'default')};
display: flex;
justify-content: center;
`;
const INPUT_TYPE_PASSWORD = 'password';
const TextInputComponent = (
{
className,
label,
value,
onChange,
onFocus,
onBlur,
onKeyDown,
fullWidth,
error,
required,
type,
disableHotkeys = false,
autoFocus,
placeholder,
disabled,
tabIndex,
RightIcon,
}: TextInputComponentProps,
// eslint-disable-next-line twenty/component-props-naming
ref: ForwardedRef<HTMLInputElement>,
): JSX.Element => {
const theme = useTheme();
const inputRef = useRef<HTMLInputElement>(null);
const combinedRef = useCombinedRefs(ref, inputRef);
const {
goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope,
} = usePreviousHotkeyScope();
const handleFocus: FocusEventHandler<HTMLInputElement> = (e) => {
onFocus?.(e);
if (!disableHotkeys) {
setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput);
}
};
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
onBlur?.(e);
if (!disableHotkeys) {
goBackToPreviousHotkeyScope();
}
};
useScopedHotkeys(
[Key.Escape, Key.Enter],
() => {
inputRef.current?.blur();
},
InputHotkeyScope.TextInput,
);
const [passwordVisible, setPasswordVisible] = useState(false);
const handleTogglePasswordVisibility = () => {
setPasswordVisible(!passwordVisible);
};
return (
<StyledContainer className={className} fullWidth={fullWidth ?? false}>
{label && <StyledLabel>{label + (required ? '*' : '')}</StyledLabel>}
<StyledInputContainer>
<StyledInput
autoComplete="off"
ref={combinedRef}
tabIndex={tabIndex ?? 0}
onFocus={handleFocus}
onBlur={handleBlur}
type={passwordVisible ? 'text' : type}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
onChange?.(event.target.value);
}}
onKeyDown={onKeyDown}
{...{ autoFocus, disabled, placeholder, required, value }}
/>
<StyledTrailingIconContainer>
{error && (
<StyledTrailingIcon>
<IconAlertCircle size={16} color={theme.color.red} />
</StyledTrailingIcon>
)}
{!error && type === INPUT_TYPE_PASSWORD && (
<StyledTrailingIcon
onClick={handleTogglePasswordVisibility}
data-testid="reveal-password-button"
>
{passwordVisible ? (
<IconEyeOff size={theme.icon.size.md} />
) : (
<IconEye size={theme.icon.size.md} />
)}
</StyledTrailingIcon>
)}
{!error && type !== INPUT_TYPE_PASSWORD && !!RightIcon && (
<StyledTrailingIcon>
<RightIcon size={theme.icon.size.md} />
</StyledTrailingIcon>
)}
</StyledTrailingIconContainer>
</StyledInputContainer>
{error && <StyledErrorHelper>{error}</StyledErrorHelper>}
</StyledContainer>
);
};
export const TextInput = forwardRef(TextInputComponent);

View File

@ -0,0 +1,86 @@
import { useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
export type ToggleSize = 'small' | 'medium';
type ContainerProps = {
isOn: boolean;
color?: string;
toggleSize: ToggleSize;
};
const StyledContainer = styled.div<ContainerProps>`
align-items: center;
background-color: ${({ theme, isOn, color }) =>
isOn ? color ?? theme.color.blue : theme.background.quaternary};
border-radius: 10px;
cursor: pointer;
display: flex;
height: ${({ toggleSize }) => (toggleSize === 'small' ? 16 : 20)}px;
transition: background-color 0.3s ease;
width: ${({ toggleSize }) => (toggleSize === 'small' ? 24 : 32)}px;
`;
const StyledCircle = styled(motion.div)<{
size: ToggleSize;
}>`
background-color: ${({ theme }) => theme.background.primary};
border-radius: 50%;
height: ${({ size }) => (size === 'small' ? 12 : 16)}px;
width: ${({ size }) => (size === 'small' ? 12 : 16)}px;
`;
export type ToggleProps = {
value?: boolean;
onChange?: (value: boolean) => void;
color?: string;
toggleSize?: ToggleSize;
className?: string;
};
export const Toggle = ({
value,
onChange,
color,
toggleSize = 'medium',
className,
}: ToggleProps) => {
const [isOn, setIsOn] = useState(value ?? false);
const circleVariants = {
on: { x: toggleSize === 'small' ? 10 : 14 },
off: { x: 2 },
};
const handleChange = () => {
setIsOn(!isOn);
if (onChange) {
onChange(!isOn);
}
};
useEffect(() => {
if (value !== isOn) {
setIsOn(value ?? false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return (
<StyledContainer
onClick={handleChange}
isOn={isOn}
color={color}
toggleSize={toggleSize}
className={className}
>
<StyledCircle
animate={isOn ? 'on' : 'off'}
variants={circleVariants}
size={toggleSize}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,48 @@
import { Meta, StoryObj } from '@storybook/react';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import {
AutosizeTextInput,
AutosizeTextInputVariant,
} from '../AutosizeTextInput';
const meta: Meta<typeof AutosizeTextInput> = {
title: 'UI/Input/AutosizeTextInput/AutosizeTextInput',
component: AutosizeTextInput,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof AutosizeTextInput>;
export const Default: Story = {};
export const ButtonVariant: Story = {
args: { variant: AutosizeTextInputVariant.Button },
};
export const Catalog: CatalogStory<Story, typeof AutosizeTextInput> = {
parameters: {
catalog: {
dimensions: [
{
name: 'variants',
values: Object.values(AutosizeTextInputVariant),
props: (variant: AutosizeTextInputVariant) => ({ variant }),
labels: (variant: AutosizeTextInputVariant) =>
`variant -> ${variant}`,
},
{
name: 'minRows',
values: [1, 4],
props: (minRows: number) => ({ minRows }),
labels: (minRows: number) => `minRows -> ${minRows}`,
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -0,0 +1,78 @@
import { Meta, StoryObj } from '@storybook/react';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import {
Checkbox,
CheckboxShape,
CheckboxSize,
CheckboxVariant,
} from '../Checkbox';
const meta: Meta<typeof Checkbox> = {
title: 'UI/Input/Checkbox/Checkbox',
component: Checkbox,
};
export default meta;
type Story = StoryObj<typeof Checkbox>;
export const Default: Story = {
args: {
checked: false,
indeterminate: false,
variant: CheckboxVariant.Primary,
size: CheckboxSize.Small,
shape: CheckboxShape.Squared,
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof Checkbox> = {
args: {},
argTypes: {
variant: { control: false },
size: { control: false },
indeterminate: { control: false },
checked: { control: false },
shape: { control: false },
},
parameters: {
catalog: {
dimensions: [
{
name: 'state',
values: ['unchecked', 'checked', 'indeterminate'],
props: (state: string) => {
if (state === 'checked') {
return { checked: true };
}
if (state === 'indeterminate') {
return { indeterminate: true };
}
return {};
},
},
{
name: 'shape',
values: Object.values(CheckboxShape),
props: (shape: CheckboxShape) => ({ shape }),
},
{
name: 'variant',
values: Object.values(CheckboxVariant),
props: (variant: CheckboxVariant) => ({ variant }),
},
{
name: 'size',
values: Object.values(CheckboxSize),
props: (size: CheckboxSize) => ({ size }),
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -0,0 +1,99 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { sleep } from '~/testing/sleep';
import { IconPicker } from '../IconPicker';
const meta: Meta<typeof IconPicker> = {
title: 'UI/Input/IconPicker/IconPicker',
component: IconPicker,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof IconPicker>;
export const Default: Story = {};
export const WithSelectedIcon: Story = {
args: { selectedIconKey: 'IconCalendarEvent' },
};
export const WithOpen: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const iconPickerButton = await canvas.findByRole('button');
userEvent.click(iconPickerButton);
},
};
export const WithOpenAndSelectedIcon: Story = {
args: { selectedIconKey: 'IconCalendarEvent' },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const iconPickerButton = await canvas.findByRole('button');
userEvent.click(iconPickerButton);
},
};
export const WithSearch: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const iconPickerButton = await canvas.findByRole('button');
userEvent.click(iconPickerButton);
const searchInput = await canvas.findByRole('textbox');
await userEvent.type(searchInput, 'Building skyscraper');
await sleep(100);
const searchedIcon = canvas.getByRole('button', {
name: 'Icon Building Skyscraper',
});
expect(searchedIcon).toBeInTheDocument();
},
};
export const WithSearchAndClose: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const iconPickerButton = await canvas.findByRole('button');
userEvent.click(iconPickerButton);
let searchInput = await canvas.findByRole('textbox');
await userEvent.type(searchInput, 'Building skyscraper');
await sleep(100);
const searchedIcon = canvas.getByRole('button', {
name: 'Icon Building Skyscraper',
});
expect(searchedIcon).toBeInTheDocument();
userEvent.click(searchedIcon);
await sleep(100);
userEvent.click(iconPickerButton);
await sleep(100);
searchInput = await canvas.findByRole('textbox');
expect(searchInput).toHaveValue('');
},
};

View File

@ -0,0 +1,21 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { workspaceLogoUrl } from '~/testing/mock-data/users';
import { ImageInput } from '../ImageInput';
const meta: Meta<typeof ImageInput> = {
title: 'UI/Input/ImageInput/ImageInput',
component: ImageInput,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof ImageInput>;
export const Default: Story = {};
export const WithPicture: Story = { args: { picture: workspaceLogoUrl } };
export const Disabled: Story = { args: { disabled: true } };

View File

@ -0,0 +1,64 @@
import { Meta, StoryObj } from '@storybook/react';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import { LabelPosition, Radio, RadioSize } from '../Radio';
const meta: Meta<typeof Radio> = {
title: 'UI/Input/Radio/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: CatalogStory<Story, typeof Radio> = {
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: LabelPosition) => ({
labelPosition,
}),
},
],
},
},
decorators: [CatalogDecorator],
};

View File

@ -0,0 +1,55 @@
import { useState } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/test';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { Select, SelectProps } from '../Select';
type RenderProps = SelectProps<string | number | null>;
const Render = (args: RenderProps) => {
const [value, setValue] = useState(args.value);
const handleChange = (value: string | number | null) => {
args.onChange?.(value);
setValue(value);
};
// eslint-disable-next-line react/jsx-props-no-spreading
return <Select {...args} value={value} onChange={handleChange} />;
};
const meta: Meta<typeof Select> = {
title: 'UI/Input/Select',
component: Select,
decorators: [ComponentDecorator],
args: {
dropdownScopeId: 'select',
value: 'a',
options: [
{ value: 'a', label: 'Option A' },
{ value: 'b', label: 'Option B' },
{ value: 'c', label: 'Option C' },
],
},
render: Render,
};
export default meta;
type Story = StoryObj<typeof Select>;
export const Default: Story = {};
export const Open: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const selectLabel = await canvas.getByText('Option A');
await userEvent.click(selectLabel);
},
};
export const Disabled: Story = {
args: { disabled: true },
};

View File

@ -0,0 +1,40 @@
import { useState } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { TextArea, TextAreaProps } from '../TextArea';
type RenderProps = TextAreaProps;
const Render = (args: RenderProps) => {
const [value, setValue] = useState(args.value);
const handleChange = (text: string) => {
args.onChange?.(text);
setValue(text);
};
// eslint-disable-next-line react/jsx-props-no-spreading
return <TextArea {...args} value={value} onChange={handleChange} />;
};
const meta: Meta<typeof TextArea> = {
title: 'UI/Input/TextArea',
component: TextArea,
decorators: [ComponentDecorator],
args: { minRows: 4, placeholder: 'Lorem Ipsum' },
render: Render,
};
export default meta;
type Story = StoryObj<typeof TextArea>;
export const Default: Story = {};
export const Filled: Story = {
args: { value: 'Lorem Ipsum' },
};
export const Disabled: Story = {
args: { disabled: true, value: 'Lorem Ipsum' },
};

View File

@ -0,0 +1,40 @@
import { useState } from 'react';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { TextInput, TextInputComponentProps } from '../TextInput';
type RenderProps = TextInputComponentProps;
const Render = (args: RenderProps) => {
const [value, setValue] = useState(args.value);
const handleChange = (text: string) => {
args.onChange?.(text);
setValue(text);
};
// eslint-disable-next-line react/jsx-props-no-spreading
return <TextInput {...args} value={value} onChange={handleChange} />;
};
const meta: Meta<typeof TextInput> = {
title: 'UI/Input/TextInput',
component: TextInput,
decorators: [ComponentDecorator],
args: { placeholder: 'Tim' },
render: Render,
};
export default meta;
type Story = StoryObj<typeof TextInput>;
export const Default: Story = {};
export const Filled: Story = {
args: { value: 'Tim' },
};
export const Disabled: Story = {
args: { disabled: true, value: 'Tim' },
};

View File

@ -0,0 +1,253 @@
import React from 'react';
import ReactDatePicker from 'react-datepicker';
import styled from '@emotion/styled';
import { overlayBackground } from '@/ui/theme/constants/effects';
import 'react-datepicker/dist/react-datepicker.css';
const StyledContainer = styled.div`
& .react-datepicker {
border-color: ${({ theme }) => theme.border.color.light};
background: transparent;
font-family: 'Inter';
font-size: ${({ theme }) => theme.font.size.md};
border: none;
display: block;
font-weight: ${({ theme }) => theme.font.weight.regular};
}
& .react-datepicker-popper {
position: relative !important;
inset: auto !important;
transform: none !important;
padding: 0 !important;
}
& .react-datepicker__triangle::after {
display: none;
}
& .react-datepicker__triangle::before {
display: none;
}
& .react-datepicker-wrapper {
display: none;
}
// Header
& .react-datepicker__header {
background: transparent;
border: none;
}
& .react-datepicker__header__dropdown {
display: flex;
color: ${({ theme }) => theme.font.color.primary};
margin-left: ${({ theme }) => theme.spacing(1)};
margin-bottom: ${({ theme }) => theme.spacing(1)};
}
& .react-datepicker__month-dropdown-container,
& .react-datepicker__year-dropdown-container {
text-align: left;
border-radius: ${({ theme }) => theme.border.radius.sm};
margin-left: ${({ theme }) => theme.spacing(1)};
margin-right: 0;
padding: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(4)};
background-color: ${({ theme }) => theme.background.tertiary};
}
& .react-datepicker__month-read-view--down-arrow,
& .react-datepicker__year-read-view--down-arrow {
height: 5px;
width: 5px;
border-width: 1px 1px 0 0;
border-color: ${({ theme }) => theme.border.color.light};
top: 3px;
right: -6px;
}
& .react-datepicker__year-read-view,
& .react-datepicker__month-read-view {
padding-right: ${({ theme }) => theme.spacing(2)};
}
& .react-datepicker__month-dropdown-container {
width: 80px;
}
& .react-datepicker__year-dropdown-container {
width: 50px;
}
& .react-datepicker__month-dropdown,
& .react-datepicker__year-dropdown {
border: ${({ theme }) => theme.border.color.light};
${overlayBackground}
overflow-y: scroll;
top: ${({ theme }) => theme.spacing(2)};
}
& .react-datepicker__month-dropdown {
left: ${({ theme }) => theme.spacing(2)};
width: 160px;
height: 260px;
}
& .react-datepicker__year-dropdown {
left: calc(${({ theme }) => theme.spacing(9)} + 80px);
width: 100px;
height: 260px;
}
& .react-datepicker__navigation--years {
display: none;
}
& .react-datepicker__month-option--selected,
& .react-datepicker__year-option--selected {
display: none;
}
& .react-datepicker__year-option,
& .react-datepicker__month-option {
text-align: left;
padding: ${({ theme }) => theme.spacing(2)}
calc(${({ theme }) => theme.spacing(2)} - 2px);
width: calc(100% - ${({ theme }) => theme.spacing(4)});
border-radius: ${({ theme }) => theme.border.radius.xs};
color: ${({ theme }) => theme.font.color.secondary};
cursor: pointer;
margin: 2px;
&:hover {
background: ${({ theme }) => theme.background.transparent.light};
}
}
& .react-datepicker__year-option {
&:first-of-type,
&:last-of-type {
display: none;
}
}
& .react-datepicker__current-month {
display: none;
}
& .react-datepicker__day-name {
color: ${({ theme }) => theme.font.color.secondary};
width: 34px;
height: 40px;
line-height: 40px;
}
& .react-datepicker__month-container {
float: none;
}
// Days
& .react-datepicker__month {
margin-top: 0;
}
& .react-datepicker__day {
width: 34px;
height: 34px;
line-height: 34px;
}
& .react-datepicker__navigation--previous,
& .react-datepicker__navigation--next {
height: 34px;
border-radius: ${({ theme }) => theme.border.radius.sm};
padding-top: 6px;
&:hover {
background: ${({ theme }) => theme.background.transparent.light};
}
}
& .react-datepicker__navigation--previous {
right: 38px;
top: 8px;
left: auto;
& > span {
margin-left: -6px;
}
}
& .react-datepicker__navigation--next {
right: 6px;
top: 8px;
& > span {
margin-left: 6px;
}
}
& .react-datepicker__navigation-icon::before {
height: 7px;
width: 7px;
border-width: 1px 1px 0 0;
border-color: ${({ theme }) => theme.font.color.tertiary};
}
& .react-datepicker__day--keyboard-selected {
background-color: inherit;
}
& .react-datepicker__day,
.react-datepicker__time-name {
color: ${({ theme }) => theme.font.color.primary};
}
& .react-datepicker__day--selected {
background-color: ${({ theme }) => theme.color.blue};
color: ${({ theme }) => theme.font.color.inverted};
}
& .react-datepicker__day--outside-month {
color: ${({ theme }) => theme.font.color.tertiary};
}
& .react-datepicker__day:hover {
color: ${({ theme }) => theme.font.color.tertiary};
}
`;
export type InternalDatePickerProps = {
date: Date;
onMouseSelect?: (date: Date) => void;
onChange?: (date: Date) => void;
};
export const InternalDatePicker = ({
date,
onChange,
onMouseSelect,
}: InternalDatePickerProps) => (
<StyledContainer>
<ReactDatePicker
open={true}
selected={date}
showMonthDropdown
showYearDropdown
onChange={() => {
// We need to use onSelect here but onChange is almost redundant with onSelect but is required
}}
customInput={<></>}
onSelect={(date: Date, event) => {
if (event?.type === 'click') {
onMouseSelect?.(date);
} else {
onChange?.(date);
}
}}
/>
</StyledContainer>
);

View File

@ -0,0 +1,48 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, userEvent, within } from '@storybook/test';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { InternalDatePicker } from '../InternalDatePicker';
const meta: Meta<typeof InternalDatePicker> = {
title: 'UI/Input/Internal/InternalDatePicker',
component: InternalDatePicker,
decorators: [ComponentDecorator],
argTypes: {
date: { control: 'date' },
},
args: { date: new Date('January 1, 2023 00:00:00') },
};
export default meta;
type Story = StoryObj<typeof InternalDatePicker>;
export const Default: Story = {};
export const WithOpenMonthSelect: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const monthSelect = canvas.getByText('January');
await userEvent.click(monthSelect);
expect(canvas.getAllByText('January')).toHaveLength(2);
[
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
].forEach((monthLabel) =>
expect(canvas.getByText(monthLabel)).toBeInTheDocument(),
);
},
};

View File

@ -0,0 +1,148 @@
import { useEffect, useMemo, useState } from 'react';
import { getCountries, getCountryCallingCode } from 'react-phone-number-input';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { hasFlag } from 'country-flag-icons';
import * as Flags from 'country-flag-icons/react/3x2';
import { CountryCallingCode } from 'libphonenumber-js';
import { IconChevronDown } from '@/ui/display/icon';
import { IconWorld } from '@/ui/input/constants/icons';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown';
import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope';
import { CountryPickerHotkeyScope } from '../types/CountryPickerHotkeyScope';
import { CountryPickerDropdownSelect } from './CountryPickerDropdownSelect';
import 'react-phone-number-input/style.css';
type StyledDropdownButtonProps = {
isUnfolded: boolean;
};
export const StyledDropdownButtonContainer = styled.div<StyledDropdownButtonProps>`
align-items: center;
background: ${({ theme }) => theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.xs};
color: ${({ color }) => color ?? 'none'};
cursor: pointer;
display: flex;
height: 32px;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
padding-right: ${({ theme }) => theme.spacing(2)};
user-select: none;
&:hover {
filter: brightness(0.95);
}
`;
const StyledIconContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: center;
svg {
align-items: center;
display: flex;
height: 16px;
justify-content: center;
}
`;
export type Country = {
countryCode: string;
countryName: string;
callingCode: CountryCallingCode;
Flag: Flags.FlagComponent;
};
export const CountryPickerDropdownButton = ({
value,
onChange,
}: {
value: string;
onChange: (countryCode: string) => void;
}) => {
const theme = useTheme();
const [selectedCountry, setSelectedCountry] = useState<Country>();
const { isDropdownOpen, closeDropdown } = useDropdown({
dropdownScopeId: 'country-picker',
});
const handleChange = (countryCode: string) => {
onChange(countryCode);
closeDropdown();
};
const countries = useMemo<Country[]>(() => {
const regionNamesInEnglish = new Intl.DisplayNames(['en'], {
type: 'region',
});
const countryCodes = getCountries();
return countryCodes.reduce<Country[]>((result, countryCode) => {
const countryName = regionNamesInEnglish.of(countryCode);
if (!countryName) return result;
if (!hasFlag(countryCode)) return result;
const Flag = Flags[countryCode];
const callingCode = getCountryCallingCode(countryCode);
result.push({
countryCode,
countryName,
callingCode,
Flag,
});
return result;
}, []);
}, []);
useEffect(() => {
const country = countries.find(({ countryCode }) => countryCode === value);
if (country) {
setSelectedCountry(country);
}
}, [countries, value]);
return (
<DropdownScope dropdownScopeId="country-picker">
<Dropdown
dropdownHotkeyScope={{ scope: CountryPickerHotkeyScope.CountryPicker }}
clickableComponent={
<StyledDropdownButtonContainer isUnfolded={isDropdownOpen}>
<StyledIconContainer>
{selectedCountry ? <selectedCountry.Flag /> : <IconWorld />}
<IconChevronDown size={theme.icon.size.sm} />
</StyledIconContainer>
</StyledDropdownButtonContainer>
}
dropdownComponents={
<CountryPickerDropdownSelect
countries={countries}
selectedCountry={selectedCountry}
onChange={handleChange}
/>
}
dropdownPlacement="bottom-start"
dropdownOffset={{ x: 0, y: 4 }}
/>
</DropdownScope>
);
};

View File

@ -0,0 +1,98 @@
import { useMemo, useState } from 'react';
import styled from '@emotion/styled';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { MenuItemSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemSelectAvatar';
import { Country } from './CountryPickerDropdownButton';
import 'react-phone-number-input/style.css';
const StyledIconContainer = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.tertiary};
display: flex;
padding-right: ${({ theme }) => theme.spacing(1)};
svg {
align-items: center;
border-radius: ${({ theme }) => theme.border.radius.xs};
display: flex;
height: 12px;
justify-content: center;
}
`;
export const CountryPickerDropdownSelect = ({
countries,
selectedCountry,
onChange,
}: {
countries: Country[];
selectedCountry?: Country;
onChange: (countryCode: string) => void;
}) => {
const [searchFilter, setSearchFilter] = useState<string>('');
const filteredCountries = useMemo(
() =>
countries.filter(({ countryName }) =>
countryName
.toLocaleLowerCase()
.includes(searchFilter.toLocaleLowerCase()),
),
[countries, searchFilter],
);
return (
<DropdownMenu width="200px" disableBlur>
<DropdownMenuSearchInput
value={searchFilter}
onChange={(event) => setSearchFilter(event.currentTarget.value)}
autoFocus
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight>
{filteredCountries?.length === 0 ? (
<MenuItem text="No result" />
) : (
<>
{selectedCountry && (
<MenuItemSelectAvatar
key={selectedCountry.countryCode}
selected={true}
onClick={() => onChange(selectedCountry.countryCode)}
text={`${selectedCountry.countryName} (+${selectedCountry.callingCode})`}
avatar={
<StyledIconContainer>
<selectedCountry.Flag />
</StyledIconContainer>
}
/>
)}
{filteredCountries.map(
({ countryCode, countryName, callingCode, Flag }) =>
selectedCountry?.countryCode === countryCode ? null : (
<MenuItemSelectAvatar
key={countryCode}
selected={selectedCountry?.countryCode === countryCode}
onClick={() => onChange(countryCode)}
text={`${countryName} (+${callingCode})`}
avatar={
<StyledIconContainer>
<Flag />
</StyledIconContainer>
}
/>
),
)}
</>
)}
</DropdownMenuItemsContainer>
</DropdownMenu>
);
};

View File

@ -0,0 +1,3 @@
export enum CountryPickerHotkeyScope {
CountryPicker = 'country-picker',
}