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
@@ -0,0 +1,40 @@
import { useTheme } from '@emotion/react';
import { motion } from 'framer-motion';
export type AnimatedCheckmarkProps = React.ComponentProps<
typeof motion.path
> & {
isAnimating?: boolean;
color?: string;
duration?: number;
size?: number;
};
export const AnimatedCheckmark = ({
isAnimating = false,
color,
duration = 0.5,
size = 28,
}: AnimatedCheckmarkProps) => {
const theme = useTheme();
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 52 52"
width={size}
height={size}
>
<motion.path
fill="none"
stroke={color ?? theme.grayScale.gray0}
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>
);
};
@@ -0,0 +1,29 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCheck } from '@/ui/display/icon';
const StyledContainer = styled.div`
align-items: center;
background-color: ${({ theme }) => theme.color.blue};
border-radius: 50%;
display: flex;
height: 20px;
justify-content: center;
width: 20px;
`;
export type CheckmarkProps = React.ComponentPropsWithoutRef<'div'> & {
className?: string;
};
export const Checkmark = ({ className }: CheckmarkProps) => {
const theme = useTheme();
return (
<StyledContainer className={className}>
<IconCheck color={theme.grayScale.gray0} size={14} />
</StyledContainer>
);
};
@@ -0,0 +1,20 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { Checkmark } from '../Checkmark';
const meta: Meta<typeof Checkmark> = {
title: 'UI/Display/Checkmark/Checkmark',
component: Checkmark,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof Checkmark>;
export const Default: Story = { args: {} };
export const WithCustomStyles: Story = {
args: { style: { backgroundColor: 'red', height: 40, width: 40 } },
};
@@ -0,0 +1,148 @@
import { MouseEvent, ReactNode } from 'react';
import styled from '@emotion/styled';
import { OverflowingTextWithTooltip } from '../../tooltip/OverflowingTextWithTooltip';
export enum ChipSize {
Large = 'large',
Small = 'small',
}
export enum ChipAccent {
TextPrimary = 'text-primary',
TextSecondary = 'text-secondary',
}
export enum ChipVariant {
Highlighted = 'highlighted',
Regular = 'regular',
Transparent = 'transparent',
Rounded = 'rounded',
}
type ChipProps = {
size?: ChipSize;
disabled?: boolean;
clickable?: boolean;
label: string;
maxWidth?: string;
variant?: ChipVariant;
accent?: ChipAccent;
leftComponent?: ReactNode;
rightComponent?: ReactNode;
className?: string;
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
};
const StyledContainer = styled.div<Partial<ChipProps>>`
align-items: center;
background-color: ${({ theme, variant }) =>
variant === ChipVariant.Highlighted
? theme.background.transparent.light
: variant === ChipVariant.Rounded
? theme.background.transparent.lighter
: 'transparent'};
border-color: ${({ theme, variant }) =>
variant === ChipVariant.Rounded ? theme.border.color.medium : 'none'};
border-radius: ${({ theme, variant }) =>
variant === ChipVariant.Rounded ? '50px' : theme.border.radius.sm};
border-style: ${({ variant }) =>
variant === ChipVariant.Rounded ? 'solid' : 'none'};
border-width: ${({ variant }) =>
variant === ChipVariant.Rounded ? '1px' : '0px'};
color: ${({ theme, disabled, accent }) =>
disabled
? theme.font.color.light
: accent === ChipAccent.TextPrimary
? theme.font.color.primary
: theme.font.color.secondary};
cursor: ${({ clickable, disabled, variant }) =>
disabled || variant === ChipVariant.Transparent
? 'inherit'
: clickable
? 'pointer'
: 'inherit'};
display: inline-flex;
font-weight: ${({ theme, accent }) =>
accent === ChipAccent.TextSecondary ? theme.font.weight.medium : 'inherit'};
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ size }) => (size === ChipSize.Large ? '16px' : '12px')};
max-width: ${({ maxWidth }) => (maxWidth ? maxWidth : '200px')};
overflow: hidden;
padding: ${({ theme, variant }) =>
variant === ChipVariant.Rounded ? '3px 8px' : theme.spacing(1)};
user-select: none;
:hover {
${({ variant, theme, disabled }) => {
if (!disabled) {
return (
'background-color: ' +
(variant === ChipVariant.Highlighted
? theme.background.transparent.medium
: variant === ChipVariant.Regular
? theme.background.transparent.light
: 'transparent') +
';'
);
}
}}
}
:active {
${({ variant, theme, disabled }) => {
if (!disabled) {
return (
'background-color: ' +
(variant === ChipVariant.Highlighted
? theme.background.transparent.strong
: variant === ChipVariant.Regular
? theme.background.transparent.medium
: 'transparent') +
';'
);
}
}}
}
`;
const StyledLabel = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const Chip = ({
size = ChipSize.Small,
label,
disabled = false,
clickable = true,
variant = ChipVariant.Regular,
leftComponent,
rightComponent,
accent = ChipAccent.TextPrimary,
maxWidth,
className,
onClick,
}: ChipProps) => (
<StyledContainer
data-testid="chip"
clickable={clickable}
variant={variant}
accent={accent}
size={size}
disabled={disabled}
className={className}
maxWidth={maxWidth}
onClick={onClick}
>
{leftComponent}
<StyledLabel>
<OverflowingTextWithTooltip text={label} />
</StyledLabel>
{rightComponent}
</StyledContainer>
);
@@ -0,0 +1,79 @@
import * as React from 'react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '@emotion/react';
import { isNonEmptyString } from '@sniptt/guards';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { Avatar, AvatarType } from '@/users/components/Avatar';
import { Chip, ChipVariant } from './Chip';
export type EntityChipProps = {
linkToEntity?: string;
entityId: string;
name: string;
avatarUrl?: string;
avatarType?: AvatarType;
variant?: EntityChipVariant;
LeftIcon?: IconComponent;
className?: string;
};
export enum EntityChipVariant {
Regular = 'regular',
Transparent = 'transparent',
}
export const EntityChip = ({
linkToEntity,
entityId,
name,
avatarUrl,
avatarType = 'rounded',
variant = EntityChipVariant.Regular,
LeftIcon,
className,
}: EntityChipProps) => {
const navigate = useNavigate();
const theme = useTheme();
const handleLinkClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (linkToEntity) {
event.preventDefault();
event.stopPropagation();
navigate(linkToEntity);
}
};
return isNonEmptyString(name) ? (
<Chip
label={name}
variant={
linkToEntity
? variant === EntityChipVariant.Regular
? ChipVariant.Highlighted
: ChipVariant.Regular
: ChipVariant.Transparent
}
leftComponent={
LeftIcon ? (
<LeftIcon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
) : (
<Avatar
avatarUrl={avatarUrl}
colorId={entityId}
placeholder={name}
size="sm"
type={avatarType}
/>
)
}
clickable={!!linkToEntity}
onClick={handleLinkClick}
className={className}
/>
) : (
<></>
);
};
@@ -0,0 +1,69 @@
import { Meta, StoryObj } from '@storybook/react';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import { Chip, ChipAccent, ChipSize, ChipVariant } from '../Chip';
const meta: Meta<typeof Chip> = {
title: 'UI/Display/Chip/Chip',
component: Chip,
};
export default meta;
type Story = StoryObj<typeof Chip>;
export const Default: Story = {
args: {
label: 'Chip test',
size: ChipSize.Small,
variant: ChipVariant.Highlighted,
accent: ChipAccent.TextPrimary,
disabled: false,
clickable: true,
maxWidth: '200px',
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof Chip> = {
args: { clickable: true, label: 'Hello' },
argTypes: {
size: { control: false },
variant: { control: false },
disabled: { control: false },
className: { control: false },
rightComponent: { control: false },
leftComponent: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.active'] },
catalog: {
dimensions: [
{
name: 'states',
values: ['default', 'hover', 'active', 'disabled'],
props: (state: string) =>
state === 'default' ? {} : { className: state },
},
{
name: 'variants',
values: Object.values(ChipVariant),
props: (variant: ChipVariant) => ({ variant }),
},
{
name: 'sizes',
values: Object.values(ChipSize),
props: (size: ChipSize) => ({ size }),
},
{
name: 'accents',
values: Object.values(ChipAccent),
props: (accent: ChipAccent) => ({ accent }),
},
],
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,21 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { EntityChip } from '../EntityChip';
const meta: Meta<typeof EntityChip> = {
title: 'UI/Display/Chip/EntityChip',
component: EntityChip,
decorators: [ComponentWithRouterDecorator],
args: {
name: 'Entity name',
linkToEntity: '/entity-link',
avatarType: 'squared',
},
};
export default meta;
type Story = StoryObj<typeof EntityChip>;
export const Default: Story = {};
@@ -0,0 +1,39 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { ThemeColor } from '@/ui/theme/constants/colors';
export type ColorSampleVariant = 'default' | 'pipeline';
const StyledColorSample = styled.div<{
colorName: ThemeColor;
variant?: ColorSampleVariant;
}>`
background-color: ${({ theme, colorName }) =>
theme.tag.background[colorName]};
border: 1px solid ${({ theme, colorName }) => theme.tag.text[colorName]};
border-radius: 60px;
height: ${({ theme }) => theme.spacing(4)};
width: ${({ theme }) => theme.spacing(3)};
${({ colorName, theme, variant }) => {
if (variant === 'pipeline')
return css`
align-items: center;
border: 0;
display: flex;
justify-content: center;
&:after {
background-color: ${theme.tag.text[colorName]};
border-radius: ${theme.border.radius.rounded};
content: '';
display: block;
height: ${theme.spacing(1)};
width: ${theme.spacing(1)};
}
`;
}}
`;
export { StyledColorSample as ColorSample };
@@ -0,0 +1,25 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { ColorSample } from '../ColorSample';
const meta: Meta<typeof ColorSample> = {
title: 'UI/Display/Color/ColorSample',
component: ColorSample,
decorators: [ComponentDecorator],
args: { colorName: 'green' },
argTypes: {
as: { control: false },
theme: { control: false },
},
};
export default meta;
type Story = StoryObj<typeof ColorSample>;
export const Default: Story = {};
export const Pipeline: Story = {
args: { variant: 'pipeline' },
};
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-address-book" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M20 6v12a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2z" />
<path d="M10 16h6" />
<path d="M13 11m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M4 8h3" />
<path d="M4 12h3" />
<path d="M4 16h3" />
</svg>

After

Width:  |  Height:  |  Size: 536 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 84 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M6.06216 1.53416C6.38434 0.663593 7.61566 0.663591 7.93784 1.53416L9.00134 4.40789C9.10263 4.68158 9.31842 4.89737 9.59211 4.99866L12.4658 6.06216C13.3364 6.38434 13.3364 7.61566 12.4658 7.93784L9.59211 9.00134C9.31842 9.10263 9.10263 9.31842 9.00134 9.59211L7.93784 12.4658C7.61566 13.3364 6.38434 13.3364 6.06216 12.4658L4.99866 9.59211C4.89737 9.31842 4.68158 9.10263 4.40789 9.00134L1.53416 7.93784C0.663593 7.61566 0.663591 6.38434 1.53416 6.06216L4.40789 4.99866C4.68158 4.89737 4.89737 4.68158 4.99866 4.40789L6.06216 1.53416Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 668 B

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.4673 3.06709C10.9938 1.64431 13.0062 1.6443 13.5327 3.06709L15.2708 7.76367C15.4364 8.21097 15.789 8.56364 16.2363 8.72917L20.9329 10.4673C22.3557 10.9938 22.3557 13.0062 20.9329 13.5327L16.2363 15.2708C15.789 15.4364 15.4364 15.789 15.2708 16.2363L13.5327 20.9329C13.0062 22.3557 10.9938 22.3557 10.4673 20.9329L8.72917 16.2363C8.56364 15.789 8.21097 15.4364 7.76367 15.2708L3.06709 13.5327C1.64431 13.0062 1.6443 10.9938 3.06709 10.4673L7.76367 8.72917C8.21097 8.56364 8.56364 8.21097 8.72917 7.76367L10.4673 3.06709Z" stroke="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 663 B

@@ -0,0 +1,12 @@
import { TablerIconsProps } from '@/ui/display/icon';
import IconAddressBookRaw from '../assets/address-book.svg?react';
type IconAddressBookProps = TablerIconsProps;
export const IconAddressBook = (props: IconAddressBookProps): JSX.Element => {
const size = props.size ?? 24;
const stroke = props.stroke ?? 2;
return <IconAddressBookRaw height={size} width={size} strokeWidth={stroke} />;
};
@@ -0,0 +1,14 @@
import { useTheme } from '@emotion/react';
import IconGoogleRaw from '../assets/google-icon.svg?react';
interface IconGoogleProps {
size?: number;
}
export const IconGoogle = (props: IconGoogleProps): JSX.Element => {
const theme = useTheme();
const size = props.size ?? theme.icon.size.lg;
return <IconGoogleRaw height={size} width={size} />;
};
@@ -0,0 +1,12 @@
import { TablerIconsProps } from '@/ui/display/icon';
import IconTwentyStarRaw from '../assets/twenty-star.svg?react';
type IconTwentyStarProps = TablerIconsProps;
export const IconTwentyStar = (props: IconTwentyStarProps): JSX.Element => {
const size = props.size ?? 24;
const stroke = props.stroke ?? 2;
return <IconTwentyStarRaw height={size} width={size} strokeWidth={stroke} />;
};
@@ -0,0 +1,16 @@
import { TablerIconsProps } from '@/ui/display/icon';
import IconTwentyStarFilledRaw from '../assets/twenty-star-filled.svg?react';
type IconTwentyStarFilledProps = TablerIconsProps;
export const IconTwentyStarFilled = (
props: IconTwentyStarFilledProps,
): JSX.Element => {
const size = props.size ?? 24;
const stroke = props.stroke ?? 2;
return (
<IconTwentyStarFilledRaw height={size} width={size} strokeWidth={stroke} />
);
};
@@ -0,0 +1,94 @@
/* eslint-disable no-restricted-imports */
export type { TablerIconsProps } from '@tabler/icons-react';
export {
IconAlertCircle,
IconAlertTriangle,
IconArchive,
IconArchiveOff,
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconArrowUp,
IconArrowUpRight,
IconAt,
IconBaselineDensitySmall,
IconBell,
IconBox,
IconBrandGithub,
IconBrandGoogle,
IconBrandLinkedin,
IconBrandX,
IconBriefcase,
IconBuildingSkyscraper,
IconCalendar,
IconCalendarEvent,
IconCheck,
IconCheckbox,
IconChevronDown,
IconChevronLeft,
IconChevronRight,
IconChevronsRight,
IconChevronUp,
IconCircleDot,
IconCoins,
IconColorSwatch,
IconMessageCircle as IconComment,
IconCopy,
IconCurrencyDollar,
IconDatabase,
IconDotsVertical,
IconDownload,
IconEye,
IconEyeOff,
IconFileCheck,
IconFileImport,
IconFileUpload,
IconForbid,
IconGripVertical,
IconHeart,
IconHeartOff,
IconHelpCircle,
IconHierarchy2,
IconKey,
IconLanguage,
IconLayoutKanban,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarRightCollapse,
IconLayoutSidebarRightExpand,
IconLink,
IconLinkOff,
IconList,
IconLogout,
IconMail,
IconMailCog,
IconMap,
IconMinus,
IconMoneybag,
IconMouse2,
IconNotes,
IconNumbers,
IconPaperclip,
IconPencil,
IconPhone,
IconPlug,
IconPlus,
IconProgressCheck,
IconRelationManyToMany,
IconRelationOneToMany,
IconRelationOneToOne,
IconRepeat,
IconRobot,
IconSearch,
IconSettings,
IconTag,
IconTarget,
IconTargetArrow,
IconTextSize,
IconTimelineEvent,
IconTrash,
IconUpload,
IconUser,
IconUserCircle,
IconUsers,
IconX,
} from '@tabler/icons-react';
@@ -0,0 +1,8 @@
import { FunctionComponent } from 'react';
export type IconComponent = FunctionComponent<{
className?: string;
color?: string;
size?: number;
stroke?: number;
}>;
@@ -0,0 +1,25 @@
import styled from '@emotion/styled';
type SoonPillProps = {
className?: string;
};
const StyledSoonPill = styled.span`
align-items: center;
background: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.pill};
color: ${({ theme }) => theme.font.color.light};
display: inline-block;
font-size: ${({ theme }) => theme.font.size.xs};
font-style: normal;
font-weight: ${({ theme }) => theme.font.weight.medium};
gap: ${({ theme }) => theme.spacing(2)};
height: ${({ theme }) => theme.spacing(4)};
justify-content: flex-end;
line-height: ${({ theme }) => theme.text.lineHeight.lg};
padding: ${({ theme }) => `0 ${theme.spacing(2)}`};
`;
export const SoonPill = ({ className }: SoonPillProps) => (
<StyledSoonPill className={className}>Soon</StyledSoonPill>
);
@@ -0,0 +1,16 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { SoonPill } from '../SoonPill';
const meta: Meta<typeof SoonPill> = {
title: 'UI/Display/Pill/SoonPill',
component: SoonPill,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof SoonPill>;
export const Default: Story = {};
@@ -0,0 +1,55 @@
import styled from '@emotion/styled';
import { ThemeColor } from '@/ui/theme/constants/colors';
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
const StyledStatus = styled.h3<{
color: ThemeColor;
}>`
align-items: center;
background: ${({ color, theme }) => theme.tag.background[color]};
border-radius: ${({ theme }) => theme.border.radius.pill};
color: ${({ color, theme }) => theme.tag.text[color]};
display: inline-flex;
font-size: ${({ theme }) => theme.font.size.md};
font-style: normal;
font-weight: ${({ theme }) => theme.font.weight.regular};
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ theme }) => theme.spacing(5)};
margin: 0;
overflow: hidden;
padding: 0 ${({ theme }) => theme.spacing(2)};
&:before {
background-color: ${({ color, theme }) => theme.tag.text[color]};
border-radius: ${({ theme }) => theme.border.radius.rounded};
content: '';
display: block;
flex-shrink: 0;
height: ${({ theme }) => theme.spacing(1)};
width: ${({ theme }) => theme.spacing(1)};
}
`;
const StyledContent = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
type StatusProps = {
className?: string;
color: ThemeColor;
text: string;
onClick?: () => void;
};
export const Status = ({ className, color, text, onClick }: StatusProps) => (
<StyledStatus
className={className}
color={themeColorSchema.catch('gray').parse(color)}
onClick={onClick}
>
<StyledContent>{text}</StyledContent>
</StyledStatus>
);
@@ -0,0 +1,65 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { mainColorNames, ThemeColor } from '@/ui/theme/constants/colors';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import { Status } from '../Status';
const meta: Meta<typeof Status> = {
title: 'UI/Display/Status/Status',
component: Status,
args: {
text: 'Urgent',
},
};
export default meta;
type Story = StoryObj<typeof Status>;
export const Default: Story = {
args: {
color: 'red',
onClick: fn(),
},
decorators: [ComponentDecorator],
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const status = canvas.getByRole('heading', { level: 3 });
await userEvent.click(status);
expect(args.onClick).toHaveBeenCalled();
},
};
export const WithLongText: Story = {
decorators: [ComponentDecorator],
args: {
color: 'green',
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
},
parameters: {
container: { width: 100 },
},
};
export const Catalog: CatalogStory<Story, typeof Status> = {
argTypes: {
color: { control: false },
},
parameters: {
catalog: {
dimensions: [
{
name: 'colors',
values: mainColorNames,
props: (color: ThemeColor) => ({ color }),
},
],
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,44 @@
import styled from '@emotion/styled';
import { ThemeColor } from '@/ui/theme/constants/colors';
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
const StyledTag = styled.h3<{
color: ThemeColor;
}>`
align-items: center;
background: ${({ color, theme }) => theme.tag.background[color]};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ color, theme }) => theme.tag.text[color]};
display: inline-flex;
font-size: ${({ theme }) => theme.font.size.md};
font-style: normal;
font-weight: ${({ theme }) => theme.font.weight.regular};
height: ${({ theme }) => theme.spacing(5)};
margin: 0;
overflow: hidden;
padding: 0 ${({ theme }) => theme.spacing(2)};
`;
const StyledContent = styled.span`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
`;
type TagProps = {
className?: string;
color: ThemeColor;
text: string;
onClick?: () => void;
};
export const Tag = ({ className, color, text, onClick }: TagProps) => (
<StyledTag
className={className}
color={themeColorSchema.catch('gray').parse(color)}
onClick={onClick}
>
<StyledContent>{text}</StyledContent>
</StyledTag>
);
@@ -0,0 +1,65 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { mainColorNames, ThemeColor } from '@/ui/theme/constants/colors';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import { Tag } from '../Tag';
const meta: Meta<typeof Tag> = {
title: 'UI/Display/Tag/Tag',
component: Tag,
args: {
text: 'Urgent',
},
};
export default meta;
type Story = StoryObj<typeof Tag>;
export const Default: Story = {
args: {
color: 'red',
onClick: fn(),
},
decorators: [ComponentDecorator],
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
const tag = canvas.getByRole('heading', { level: 3 });
await userEvent.click(tag);
await expect(args.onClick).toHaveBeenCalled();
},
};
export const WithLongText: Story = {
decorators: [ComponentDecorator],
args: {
color: 'green',
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
},
parameters: {
container: { width: 100 },
},
};
export const Catalog: CatalogStory<Story, typeof Tag> = {
argTypes: {
color: { control: false },
},
parameters: {
catalog: {
dimensions: [
{
name: 'colors',
values: mainColorNames,
props: (color: ThemeColor) => ({ color }),
},
],
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,70 @@
import { PlacesType, PositionStrategy, Tooltip } from 'react-tooltip';
import styled from '@emotion/styled';
import { rgba } from '../../theme/constants/colors';
export enum TooltipPosition {
Top = 'top',
Left = 'left',
Right = 'right',
Bottom = 'bottom',
}
const StyledAppTooltip = styled(Tooltip)`
backdrop-filter: ${({ theme }) => theme.blur.strong};
background-color: ${({ theme }) => rgba(theme.color.gray80, 0.8)};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: ${({ theme }) => theme.boxShadow.light};
color: ${({ theme }) => theme.grayScale.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};
`;
export type AppTooltipProps = {
className?: string;
anchorSelect?: string;
content?: string;
delayHide?: number;
offset?: number;
noArrow?: boolean;
isOpen?: boolean;
place?: PlacesType;
positionStrategy?: PositionStrategy;
};
export const AppTooltip = ({
anchorSelect,
className,
content,
delayHide,
isOpen,
noArrow,
offset,
place,
positionStrategy,
}: AppTooltipProps) => (
<StyledAppTooltip
{...{
anchorSelect,
className,
content,
delayHide,
isOpen,
noArrow,
offset,
place,
positionStrategy,
}}
/>
);
@@ -0,0 +1,80 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import styled from '@emotion/styled';
import { v4 as uuidV4 } from 'uuid';
import { AppTooltip } from './AppTooltip';
const StyledOverflowingText = styled.div<{ cursorPointer: boolean }>`
cursor: ${({ cursorPointer }) => (cursorPointer ? 'pointer' : 'inherit')};
font-family: inherit;
font-size: inherit;
font-weight: inherit;
max-width: 100%;
overflow: hidden;
text-decoration: inherit;
text-overflow: ellipsis;
white-space: nowrap;
`;
export const OverflowingTextWithTooltip = ({
text,
className,
}: {
text: string | null | undefined;
className?: string;
}) => {
const textElementId = `title-id-${uuidV4()}`;
const textRef = useRef<HTMLDivElement>(null);
const [isTitleOverflowing, setIsTitleOverflowing] = useState(false);
useEffect(() => {
const isOverflowing =
(text?.length ?? 0) > 0 && textRef.current
? textRef.current?.scrollHeight > textRef.current?.clientHeight ||
textRef.current.scrollWidth > textRef.current.clientWidth
: false;
if (isTitleOverflowing !== isOverflowing) {
setIsTitleOverflowing(isOverflowing);
}
}, [isTitleOverflowing, text]);
const handleTooltipClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
event.preventDefault();
};
return (
<>
<StyledOverflowingText
data-testid="tooltip"
className={className}
ref={textRef}
id={textElementId}
cursorPointer={isTitleOverflowing}
>
{text}
</StyledOverflowingText>
{isTitleOverflowing &&
createPortal(
<div onClick={handleTooltipClick}>
<AppTooltip
anchorSelect={`#${textElementId}`}
content={text ?? ''}
delayHide={0}
offset={5}
noArrow
place="bottom"
positionStrategy="absolute"
/>
</div>,
document.body,
)}
</>
);
};
@@ -0,0 +1,29 @@
import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/test';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { OverflowingTextWithTooltip } from '../OverflowingTextWithTooltip';
const placeholderText =
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi tellus diam, rhoncus nec consequat quis, dapibus quis massa. Praesent tincidunt augue at ex bibendum, non finibus augue faucibus. In at gravida orci. Nulla facilisi. Proin ut augue ut nisi pellentesque tristique. Proin sodales libero id turpis tincidunt posuere.';
const meta: Meta<typeof OverflowingTextWithTooltip> = {
title: 'UI/Display/Tooltip/OverflowingTextWithTooltip',
component: OverflowingTextWithTooltip,
};
export default meta;
type Story = StoryObj<typeof OverflowingTextWithTooltip>;
export const Default: Story = {
args: {
text: placeholderText,
},
decorators: [ComponentDecorator],
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const tooltip = await canvas.findByTestId('tooltip');
userEvent.hover(tooltip);
},
};
@@ -0,0 +1,82 @@
import { Meta, StoryObj } from '@storybook/react';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import { AppTooltip as Tooltip, TooltipPosition } from '../AppTooltip';
const meta: Meta<typeof Tooltip> = {
title: 'UI/Display/Tooltip/Tooltip',
component: Tooltip,
};
export default meta;
type Story = StoryObj<typeof Tooltip>;
export const Default: Story = {
args: {
place: TooltipPosition.Bottom,
content: 'Tooltip Test',
isOpen: true,
anchorSelect: '#hover-text',
},
decorators: [ComponentDecorator],
render: ({
anchorSelect,
className,
content,
delayHide,
isOpen,
noArrow,
offset,
place,
positionStrategy,
}) => (
<>
<p id="hover-text" data-testid="tooltip">
Hover me!
</p>
<Tooltip
{...{
anchorSelect,
className,
content,
delayHide,
isOpen,
noArrow,
offset,
place,
positionStrategy,
}}
/>
</>
),
};
export const Catalog: CatalogStory<Story, typeof Tooltip> = {
args: { isOpen: true, content: 'Tooltip Test' },
play: async ({ canvasElement }) => {
Object.values(TooltipPosition).forEach((position) => {
const element = canvasElement.querySelector(
`#${position}`,
) as HTMLElement;
element.style.margin = '75px';
});
},
parameters: {
catalog: {
dimensions: [
{
name: 'anchorSelect',
values: Object.values(TooltipPosition),
props: (anchorSelect: TooltipPosition) => ({
anchorSelect: `#${anchorSelect}`,
place: anchorSelect,
}),
},
],
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,36 @@
import styled from '@emotion/styled';
type H1TitleProps = {
title: string;
fontColor?: H1TitleFontColor;
className?: string;
};
export enum H1TitleFontColor {
Primary = 'primary',
Secondary = 'secondary',
Tertiary = 'tertiary',
}
const StyledTitle = styled.h2<{
fontColor: H1TitleFontColor;
}>`
color: ${({ theme, fontColor }) => theme.font.color[fontColor]};
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
line-height: ${({ theme }) => theme.text.lineHeight.md};
margin: 0;
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
export const H1Title = ({
title,
fontColor = H1TitleFontColor.Tertiary,
className,
}: H1TitleProps) => {
return (
<StyledTitle fontColor={fontColor} className={className}>
{title}
</StyledTitle>
);
};
@@ -0,0 +1,50 @@
import styled from '@emotion/styled';
type H2TitleProps = {
title: string;
description?: string;
addornment?: React.ReactNode;
className?: string;
};
const StyledContainer = styled.div`
display: flex;
flex-direction: column;
margin-bottom: ${({ theme }) => theme.spacing(4)};
`;
const StyledTitleContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
`;
const StyledTitle = styled.h2`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
margin: 0;
`;
const StyledDescription = styled.h3`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.regular};
margin: 0;
margin-top: ${({ theme }) => theme.spacing(3)};
`;
export const H2Title = ({
title,
description,
addornment,
className,
}: H2TitleProps) => (
<StyledContainer className={className}>
<StyledTitleContainer>
<StyledTitle>{title}</StyledTitle>
{addornment}
</StyledTitleContainer>
{description && <StyledDescription>{description}</StyledDescription>}
</StyledContainer>
);
@@ -0,0 +1,43 @@
import { Meta, StoryObj } from '@storybook/react';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import { H1Title, H1TitleFontColor } from '../H1Title';
const meta: Meta<typeof H1Title> = {
title: 'UI/Display/Typography/Title/H1Title',
component: H1Title,
decorators: [ComponentDecorator],
};
export default meta;
type Story = StoryObj<typeof H1Title>;
const args = {
title: 'Title',
fontColor: H1TitleFontColor.Primary,
};
export const Default: Story = {
args,
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof H1Title> = {
args,
decorators: [CatalogDecorator],
parameters: {
catalog: {
dimensions: [
{
name: 'FontColor',
values: Object.values(H1TitleFontColor),
props: (fontColor: H1TitleFontColor) => ({ fontColor }),
},
],
},
},
};
@@ -0,0 +1,32 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { H2Title } from '../H2Title';
const args = {
title: 'Sub title',
description: 'Lorem ipsum dolor sit amet',
};
const meta: Meta<typeof H2Title> = {
title: 'UI/Display/Typography/Title/H2Title',
component: H2Title,
decorators: [ComponentDecorator],
args: {
title: args.title,
},
};
export default meta;
type Story = StoryObj<typeof H2Title>;
export const Default: Story = {
decorators: [ComponentDecorator],
};
export const WithDescription: Story = {
args,
decorators: [ComponentDecorator],
};
@@ -0,0 +1,162 @@
import { useCallback } from 'react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { Key } from 'ts-key-enum';
import { Button } from '@/ui/input/button/components/Button';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { DialogHotkeyScope } from '../types/DialogHotkeyScope';
const StyledDialogOverlay = 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 StyledDialogContainer = 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 StyledDialogTitle = 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 StyledDialogMessage = 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 StyledDialogButton = styled(Button)`
justify-content: center;
margin-bottom: ${({ theme }) => theme.spacing(2)};
`;
export type DialogButtonOptions = Omit<
React.ComponentProps<typeof Button>,
'fullWidth'
> & {
onClick?: (
event: React.MouseEvent<HTMLButtonElement, MouseEvent> | KeyboardEvent,
) => void;
role?: 'confirm';
};
export type DialogProps = React.ComponentPropsWithoutRef<typeof motion.div> & {
title?: string;
message?: string;
buttons?: DialogButtonOptions[];
allowDismiss?: boolean;
children?: React.ReactNode;
className?: string;
onClose?: () => void;
};
export const Dialog = ({
title,
message,
buttons = [],
allowDismiss = true,
children,
className,
onClose,
id,
}: DialogProps) => {
const closeSnackbar = useCallback(() => {
onClose && onClose();
}, [onClose]);
const dialogVariants = {
open: { opacity: 1 },
closed: { opacity: 0 },
};
const containerVariants = {
open: { y: 0 },
closed: { y: '50vh' },
};
useScopedHotkeys(
Key.Enter,
(event: KeyboardEvent) => {
const confirmButton = buttons.find((button) => button.role === 'confirm');
event.preventDefault();
if (confirmButton) {
confirmButton?.onClick?.(event);
closeSnackbar();
}
},
DialogHotkeyScope.Dialog,
[],
);
useScopedHotkeys(
Key.Escape,
(event: KeyboardEvent) => {
event.preventDefault();
closeSnackbar();
},
DialogHotkeyScope.Dialog,
[],
);
return (
<StyledDialogOverlay
variants={dialogVariants}
initial="closed"
animate="open"
exit="closed"
onClick={(e) => {
if (allowDismiss) {
e.stopPropagation();
closeSnackbar();
}
}}
className={className}
>
<StyledDialogContainer
variants={containerVariants}
transition={{ damping: 15, stiffness: 100 }}
id={id}
>
{title && <StyledDialogTitle>{title}</StyledDialogTitle>}
{message && <StyledDialogMessage>{message}</StyledDialogMessage>}
{children}
{buttons.map(({ accent, onClick, role, title: key, variant }) => (
<StyledDialogButton
onClick={(event) => {
onClick?.(event);
closeSnackbar();
}}
fullWidth={true}
variant={variant ?? 'secondary'}
{...{ accent, key, role }}
/>
))}
</StyledDialogContainer>
</StyledDialogOverlay>
);
};
@@ -0,0 +1,24 @@
import { useDialogManagerScopedStates } from '../hooks/internal/useDialogManagerScopedStates';
import { useDialogManager } from '../hooks/useDialogManager';
import { Dialog } from './Dialog';
import { DialogManagerEffect } from './DialogManagerEffect';
export const DialogManager = ({ children }: React.PropsWithChildren) => {
const { dialogInternal } = useDialogManagerScopedStates();
const { closeDialog } = useDialogManager();
return (
<>
<DialogManagerEffect />
{children}
{dialogInternal.queue.map(({ buttons, children, id, message, title }) => (
<Dialog
key={id}
{...{ title, message, buttons, id, children }}
onClose={() => closeDialog(id)}
/>
))}
</>
);
};
@@ -0,0 +1,22 @@
import { useEffect } from 'react';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useDialogManagerScopedStates } from '../hooks/internal/useDialogManagerScopedStates';
import { DialogHotkeyScope } from '../types/DialogHotkeyScope';
export const DialogManagerEffect = () => {
const { dialogInternal } = useDialogManagerScopedStates();
const { setHotkeyScopeAndMemorizePreviousScope } = usePreviousHotkeyScope();
useEffect(() => {
if (dialogInternal.queue.length === 0) {
return;
}
setHotkeyScopeAndMemorizePreviousScope(DialogHotkeyScope.Dialog);
}, [dialogInternal.queue, setHotkeyScopeAndMemorizePreviousScope]);
return <></>;
};
@@ -0,0 +1,25 @@
import { useRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedStateV2';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { DialogManagerScopeInternalContext } from '../../scopes/scope-internal-context/DialogManagerScopeInternalContext';
import { dialogInternalScopedState } from '../../states/dialogInternalScopedState';
type useDialogManagerScopedStatesProps = {
dialogManagerScopeId?: string;
};
export const useDialogManagerScopedStates = (
props?: useDialogManagerScopedStatesProps,
) => {
const scopeId = useAvailableScopeIdOrThrow(
DialogManagerScopeInternalContext,
props?.dialogManagerScopeId,
);
const [dialogInternal, setDialogInternal] = useRecoilScopedStateV2(
dialogInternalScopedState,
scopeId,
);
return { dialogInternal, setDialogInternal };
};
@@ -0,0 +1,62 @@
import { useRecoilCallback } from 'recoil';
import { v4 } from 'uuid';
import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { DialogManagerScopeInternalContext } from '../scopes/scope-internal-context/DialogManagerScopeInternalContext';
import { dialogInternalScopedState } from '../states/dialogInternalScopedState';
import { DialogOptions } from '../types/DialogOptions';
type useDialogManagerProps = {
dialogManagerScopeId?: string;
};
export const useDialogManager = (props?: useDialogManagerProps) => {
const scopeId = useAvailableScopeIdOrThrow(
DialogManagerScopeInternalContext,
props?.dialogManagerScopeId,
);
const { goBackToPreviousHotkeyScope } = usePreviousHotkeyScope();
const closeDialog = useRecoilCallback(
({ set }) =>
(id: string) => {
set(dialogInternalScopedState({ scopeId: scopeId }), (prevState) => ({
...prevState,
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
}));
goBackToPreviousHotkeyScope();
},
[goBackToPreviousHotkeyScope, scopeId],
);
const setDialogQueue = useRecoilCallback(
({ set }) =>
(newValue) =>
set(dialogInternalScopedState({ scopeId: scopeId }), (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[],
};
}),
[scopeId],
);
const enqueueDialog = (options?: Omit<DialogOptions, 'id'>) => {
setDialogQueue({
id: v4(),
...options,
});
};
return { closeDialog, enqueueDialog };
};
@@ -0,0 +1,23 @@
import { ReactNode } from 'react';
import { DialogManagerScopeInternalContext } from './scope-internal-context/DialogManagerScopeInternalContext';
type DialogManagerScopeProps = {
children: ReactNode;
dialogManagerScopeId: string;
};
export const DialogManagerScope = ({
children,
dialogManagerScopeId,
}: DialogManagerScopeProps) => {
return (
<DialogManagerScopeInternalContext.Provider
value={{
scopeId: dialogManagerScopeId,
}}
>
{children}
</DialogManagerScopeInternalContext.Provider>
);
};
@@ -0,0 +1,7 @@
import { ScopedStateKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopedStateKey';
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
type DialogManagerScopeInternalContextProps = ScopedStateKey;
export const DialogManagerScopeInternalContext =
createScopeInternalContext<DialogManagerScopeInternalContextProps>();
@@ -0,0 +1,16 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
import { DialogOptions } from '../types/DialogOptions';
type DialogState = {
maxQueue: number;
queue: DialogOptions[];
};
export const dialogInternalScopedState = createScopedState<DialogState>({
key: 'dialog/internal-state',
defaultValue: {
maxQueue: 2,
queue: [],
},
});
@@ -0,0 +1,3 @@
export enum DialogHotkeyScope {
Dialog = 'dialog',
}
@@ -0,0 +1,5 @@
import { DialogProps } from '../components/Dialog';
export type DialogOptions = DialogProps & {
id: string;
};
@@ -0,0 +1,71 @@
import React, { useEffect, useMemo } from 'react';
import { motion, useAnimation } from 'framer-motion';
interface CircularProgressBarProps {
size?: number;
barWidth?: number;
barColor?: string;
}
export const CircularProgressBar = ({
size = 50,
barWidth = 5,
barColor = 'currentColor',
}: CircularProgressBarProps) => {
const controls = useAnimation();
const circumference = useMemo(
() => 2 * Math.PI * (size / 2 - barWidth),
[size, barWidth],
);
useEffect(() => {
const animateIndeterminate = async () => {
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>
);
};
@@ -0,0 +1,110 @@
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
} from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { AnimationControls, motion, useAnimation } from 'framer-motion';
export type ProgressBarProps = {
duration?: number;
delay?: number;
easing?: string;
barHeight?: number;
barColor?: string;
autoStart?: boolean;
className?: string;
};
export type StyledBarProps = {
barHeight?: number;
className?: string;
};
export type ProgressBarControls = AnimationControls & {
start: () => Promise<any>;
pause: () => Promise<any>;
};
const StyledBar = styled.div<StyledBarProps>`
height: ${({ barHeight }) => barHeight}px;
overflow: hidden;
width: 100%;
`;
const StyledBarFilling = styled(motion.div)`
height: 100%;
width: 100%;
`;
export const ProgressBar = forwardRef<ProgressBarControls, ProgressBarProps>(
(
{
duration = 3,
delay = 0,
easing = 'easeInOut',
barHeight = 24,
barColor,
autoStart = true,
className,
},
ref,
) => {
const theme = useTheme();
const controls = useAnimation();
// eslint-disable-next-line twenty/no-state-useref
const startTimestamp = useRef<number>(0);
// eslint-disable-next-line twenty/no-state-useref
const remainingTime = useRef<number>(duration);
const start = useCallback(async () => {
startTimestamp.current = Date.now();
return controls.start({
scaleX: 0,
transition: {
duration: remainingTime.current / 1000, // convert ms to s for framer-motion
delay: delay / 1000, // likewise
ease: easing,
},
});
}, [controls, delay, easing]);
useImperativeHandle(ref, () => ({
...controls,
start: async () => {
return start();
},
pause: async () => {
const elapsed = Date.now() - startTimestamp.current;
remainingTime.current = remainingTime.current - elapsed;
return controls.stop();
},
}));
useEffect(() => {
if (autoStart) {
start();
}
}, [controls, delay, duration, easing, autoStart, start]);
return (
<StyledBar className={className} barHeight={barHeight}>
<StyledBarFilling
style={{
originX: 0,
// Seems like custom props are not well handled by react when used with framer-motion and emotion styled
backgroundColor: barColor ?? theme.color.gray80,
}}
initial={{ scaleX: 1 }}
animate={controls}
exit={{ scaleX: 0 }}
/>
</StyledBar>
);
},
);
@@ -0,0 +1,56 @@
import { Meta, StoryObj } from '@storybook/react';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import { CircularProgressBar } from '../CircularProgressBar';
const meta: Meta<typeof CircularProgressBar> = {
title: 'UI/Feedback/CircularProgressBar/CircularProgressBar',
component: CircularProgressBar,
args: {
size: 50,
},
parameters: {
chromatic: { disableSnapshot: true },
},
};
export default meta;
type Story = StoryObj<typeof CircularProgressBar>;
export const Default: Story = {
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof CircularProgressBar> = {
argTypes: {},
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],
};
@@ -0,0 +1,63 @@
import { Meta, StoryObj } from '@storybook/react';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import { ProgressBar } from '../ProgressBar';
const meta: Meta<typeof ProgressBar> = {
title: 'UI/Feedback/ProgressBar/ProgressBar',
component: ProgressBar,
args: {
duration: 10000,
},
};
export default meta;
type Story = StoryObj<typeof ProgressBar>;
const args = {};
const defaultArgTypes = {
control: false,
};
export const Default: Story = {
args,
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof ProgressBar> = {
args: {
...args,
},
argTypes: {
barHeight: defaultArgTypes,
barColor: defaultArgTypes,
autoStart: defaultArgTypes,
},
parameters: {
catalog: {
dimensions: [
{
name: 'animation',
values: [true, false],
props: (autoStart: string) => ({ autoStart: Boolean(autoStart) }),
labels: (autoStart: string) => `AutoStart: ${autoStart}`,
},
{
name: 'colors',
values: [undefined, 'blue'],
props: (barColor: string) => ({ barColor }),
labels: (color: string) => `Color: ${color ?? 'default'}`,
},
{
name: 'sizes',
values: [undefined, 10],
props: (barHeight: number) => ({ barHeight }),
labels: (size: number) => `Size: ${size ? size + ' px' : 'default'}`,
},
],
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,183 @@
import { useCallback, useMemo, useRef } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconAlertTriangle, IconX } from '@/ui/display/icon';
import {
ProgressBar,
ProgressBarControls,
} from '@/ui/feedback/progress-bar/components/ProgressBar';
import { rgba } from '@/ui/theme/constants/colors';
import { usePausableTimeout } from '../hooks/usePausableTimeout';
const StyledMotionContainer = styled.div<Pick<SnackBarProps, 'variant'>>`
align-items: center;
background-color: ${({ theme, variant }) => {
switch (variant) {
case 'error':
return theme.snackBar.error.background;
case 'success':
return theme.snackBar.success.background;
case 'info':
default:
return theme.color.gray80;
}
}};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
color: ${({ theme, variant }) => {
switch (variant) {
case 'error':
return theme.snackBar.error.color;
case 'success':
return theme.snackBar.success.color;
case 'info':
default:
return theme.grayScale.gray0;
}
}};
cursor: pointer;
display: flex;
height: 40px;
overflow: hidden;
padding: ${({ theme }) => theme.spacing(2)};
pointer-events: auto;
position: relative;
`;
const StyledIconContainer = styled.div`
display: flex;
margin-right: ${({ theme }) => theme.spacing(2)};
`;
const StyledProgressBarContainer = styled.div`
height: 5px;
left: 0;
position: absolute;
right: 0;
top: 0;
`;
const StyledCloseButton = styled.button<Pick<SnackBarProps, 'variant'>>`
align-items: center;
background-color: transparent;
border: none;
border-radius: 12px;
color: ${({ theme, variant }) => {
switch (variant) {
case 'error':
return theme.color.red20;
case 'success':
return theme.color.turquoise20;
case 'info':
default:
return theme.grayScale.gray0;
}
}};
cursor: pointer;
display: flex;
height: 24px;
justify-content: center;
margin-left: ${({ theme }) => theme.spacing(6)};
padding-left: ${({ theme }) => theme.spacing(1)};
padding-right: ${({ theme }) => theme.spacing(1)};
width: 24px;
&:hover {
background-color: ${({ theme }) => rgba(theme.grayScale.gray0, 0.1)};
}
`;
export type SnackbarVariant = 'info' | 'error' | 'success';
export interface SnackBarProps extends React.ComponentPropsWithoutRef<'div'> {
role?: 'alert' | 'status';
icon?: React.ReactNode;
message?: string;
allowDismiss?: boolean;
duration?: number;
variant?: SnackbarVariant;
children?: React.ReactNode;
className?: string;
onClose?: () => void;
}
export const SnackBar = ({
role = 'status',
icon: iconComponent,
message,
allowDismiss = true,
duration = 6000,
variant = 'info',
children,
onClose,
id,
title,
className,
}: SnackBarProps) => {
const theme = useTheme();
// eslint-disable-next-line twenty/no-state-useref
const progressBarRef = useRef<ProgressBarControls | null>(null);
const closeSnackbar = useCallback(() => {
onClose && onClose();
}, [onClose]);
const { pauseTimeout, resumeTimeout } = usePausableTimeout(
closeSnackbar,
duration,
);
const icon = useMemo(() => {
if (iconComponent) {
return iconComponent;
}
switch (variant) {
case 'error':
return (
<IconAlertTriangle aria-label="Error" size={theme.icon.size.md} />
);
case 'success':
case 'info':
default:
return null;
}
}, [iconComponent, theme.icon.size.md, variant]);
const onMouseEnter = () => {
progressBarRef.current?.pause();
pauseTimeout();
};
const onMouseLeave = () => {
progressBarRef.current?.start();
resumeTimeout();
};
return (
<StyledMotionContainer
className={className}
aria-live={role === 'alert' ? 'assertive' : 'polite'}
{...{ id, onMouseEnter, onMouseLeave, role, title, variant }}
>
<StyledProgressBarContainer>
<ProgressBar
ref={progressBarRef}
barHeight={5}
barColor={rgba(theme.grayScale.gray0, 0.3)}
duration={duration}
/>
</StyledProgressBarContainer>
{icon && <StyledIconContainer>{icon}</StyledIconContainer>}
{children ? children : message}
{allowDismiss && (
<StyledCloseButton variant={variant} onClick={closeSnackbar}>
<IconX aria-label="Close" size={theme.icon.size.md} />
</StyledCloseButton>
)}
</StyledMotionContainer>
);
};
@@ -0,0 +1,83 @@
import styled from '@emotion/styled';
import { motion, useReducedMotion } from 'framer-motion';
import { useSnackBarManagerScopedStates } from '@/ui/feedback/snack-bar-manager/hooks/internal/useSnackBarManagerScopedStates';
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
import { SnackBar } from './SnackBar';
const StyledSnackBarContainer = styled.div`
display: flex;
flex-direction: column;
position: fixed;
right: 0;
top: 0;
z-index: 99999999;
`;
const StyledSnackBarMotionContainer = styled(motion.div)`
margin-right: ${({ theme }) => theme.spacing(3)};
margin-top: ${({ theme }) => theme.spacing(3)};
`;
const variants = {
initial: {
opacity: 0,
y: -40,
},
animate: {
opacity: 1,
y: 0,
},
exit: {
opacity: 0,
y: -40,
},
};
const reducedVariants = {
initial: {
opacity: 0,
y: -40,
},
animate: {
opacity: 1,
y: 0,
},
exit: {
opacity: 0,
y: -40,
},
};
export const SnackBarProvider = ({ children }: React.PropsWithChildren) => {
const reducedMotion = useReducedMotion();
const { snackBarInternal } = useSnackBarManagerScopedStates();
const { handleSnackBarClose } = useSnackBar();
return (
<>
{children}
<StyledSnackBarContainer>
{snackBarInternal.queue.map(
({ duration, icon, id, message, title, variant }) => (
<StyledSnackBarMotionContainer
key={id}
variants={reducedMotion ? reducedVariants : variants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.5 }}
layout
>
<SnackBar
{...{ duration, icon, message, title, variant }}
onClose={() => handleSnackBarClose(id)}
/>
</StyledSnackBarMotionContainer>
),
)}
</StyledSnackBarContainer>
</>
);
};
@@ -0,0 +1,24 @@
import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext';
import { snackBarInternalScopedState } from '@/ui/feedback/snack-bar-manager/states/snackBarInternalScopedState';
import { useRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedStateV2';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
type useSnackBarManagerScopedStatesProps = {
snackBarManagerScopeId?: string;
};
export const useSnackBarManagerScopedStates = (
props?: useSnackBarManagerScopedStatesProps,
) => {
const scopeId = useAvailableScopeIdOrThrow(
SnackBarManagerScopeInternalContext,
props?.snackBarManagerScopeId,
);
const [snackBarInternal, setSnackBarInternal] = useRecoilScopedStateV2(
snackBarInternalScopedState,
scopeId,
);
return { snackBarInternal, setSnackBarInternal };
};
@@ -0,0 +1,54 @@
import { useCallback, useEffect, useRef } from 'react';
export const usePausableTimeout = (callback: () => void, delay: number) => {
// eslint-disable-next-line twenty/no-state-useref
const savedCallback = useRef<() => void>(callback);
// eslint-disable-next-line twenty/no-state-useref
const remainingTime = useRef<number>(delay);
// eslint-disable-next-line twenty/no-state-useref
const startTime = useRef<number>(Date.now());
// eslint-disable-next-line twenty/no-state-useref
const timeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
const tick = () => {
if (savedCallback.current) {
savedCallback.current();
}
};
const startTimeout = useCallback(() => {
startTime.current = Date.now();
timeoutId.current = setTimeout(tick, remainingTime.current);
}, []);
// Remember the latest callback
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
// Set up the timeout loop
useEffect(() => {
if (delay !== null) {
startTimeout();
return () => {
if (timeoutId.current) {
clearTimeout(timeoutId.current);
}
};
}
}, [delay, startTimeout]);
const pauseTimeout = () => {
if (timeoutId.current) {
clearTimeout(timeoutId.current);
}
const elapsedTime = Date.now() - startTime.current;
remainingTime.current = remainingTime.current - elapsedTime;
};
const resumeTimeout = () => {
startTimeout();
};
return { pauseTimeout, resumeTimeout };
};
@@ -0,0 +1,55 @@
import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
import { v4 as uuidv4 } from 'uuid';
import { SnackBarManagerScopeInternalContext } from '@/ui/feedback/snack-bar-manager/scopes/scope-internal-context/SnackBarManagerScopeInternalContext';
import {
snackBarInternalScopedState,
SnackBarOptions,
} from '@/ui/feedback/snack-bar-manager/states/snackBarInternalScopedState';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
export const useSnackBar = () => {
const scopeId = useAvailableScopeIdOrThrow(
SnackBarManagerScopeInternalContext,
);
const handleSnackBarClose = useRecoilCallback(({ set }) => (id: string) => {
set(snackBarInternalScopedState({ scopeId }), (prevState) => ({
...prevState,
queue: prevState.queue.filter((snackBar) => snackBar.id !== id),
}));
});
const setSnackBarQueue = useRecoilCallback(
({ set }) =>
(newValue) =>
set(snackBarInternalScopedState({ scopeId }), (prev) => {
if (prev.queue.length >= prev.maxQueue) {
return {
...prev,
queue: [...prev.queue.slice(1), newValue] as SnackBarOptions[],
};
}
return {
...prev,
queue: [...prev.queue, newValue] as SnackBarOptions[],
};
}),
[scopeId],
);
const enqueueSnackBar = useCallback(
(message: string, options?: Omit<SnackBarOptions, 'message' | 'id'>) => {
setSnackBarQueue({
id: uuidv4(),
message,
...options,
});
},
[setSnackBarQueue],
);
return { handleSnackBarClose, enqueueSnackBar };
};
@@ -0,0 +1,23 @@
import { ReactNode } from 'react';
import { SnackBarManagerScopeInternalContext } from './scope-internal-context/SnackBarManagerScopeInternalContext';
type SnackBarProviderScopeProps = {
children: ReactNode;
snackBarManagerScopeId: string;
};
export const SnackBarProviderScope = ({
children,
snackBarManagerScopeId,
}: SnackBarProviderScopeProps) => {
return (
<SnackBarManagerScopeInternalContext.Provider
value={{
scopeId: snackBarManagerScopeId,
}}
>
{children}
</SnackBarManagerScopeInternalContext.Provider>
);
};
@@ -0,0 +1,7 @@
import { ScopedStateKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/ScopedStateKey';
import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext';
type SnackBarManagerScopeInternalContextProps = ScopedStateKey;
export const SnackBarManagerScopeInternalContext =
createScopeInternalContext<SnackBarManagerScopeInternalContextProps>();
@@ -0,0 +1,20 @@
import { createScopedState } from '@/ui/utilities/recoil-scope/utils/createScopedState';
import { SnackBarProps } from '../components/SnackBar';
export type SnackBarOptions = SnackBarProps & {
id: string;
};
type SnackBarState = {
maxQueue: number;
queue: SnackBarOptions[];
};
export const snackBarInternalScopedState = createScopedState<SnackBarState>({
key: 'snackBarState',
defaultValue: {
maxQueue: 3,
queue: [],
},
});
@@ -0,0 +1,10 @@
import { EllipsisDisplay } from './EllipsisDisplay';
type CurrencyDisplayProps = {
amount?: number | null;
};
// TODO: convert currencyCode to currency symbol
export const CurrencyDisplay = ({ amount }: CurrencyDisplayProps) => {
return <EllipsisDisplay>{amount}</EllipsisDisplay>;
};
@@ -0,0 +1,11 @@
import { formatToHumanReadableDate } from '~/utils';
import { EllipsisDisplay } from './EllipsisDisplay';
type DateDisplayProps = {
value: Date | string | null | undefined;
};
export const DateDisplay = ({ value }: DateDisplayProps) => (
<EllipsisDisplay>{value && formatToHumanReadableDate(value)}</EllipsisDisplay>
);
@@ -0,0 +1,3 @@
import { TextDisplay } from './TextDisplay';
export const DoubleTextDisplay = TextDisplay;
@@ -0,0 +1,10 @@
import styled from '@emotion/styled';
const StyledEllipsisDisplay = styled.div`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
`;
export { StyledEllipsisDisplay as EllipsisDisplay };
@@ -0,0 +1,31 @@
import { MouseEvent } from 'react';
import { ContactLink } from '@/ui/navigation/link/components/ContactLink';
import { EllipsisDisplay } from './EllipsisDisplay';
const validateEmail = (email: string) => {
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailPattern.test(email.trim());
};
type EmailDisplayProps = {
value: string | null;
};
export const EmailDisplay = ({ value }: EmailDisplayProps) => (
<EllipsisDisplay>
{value && validateEmail(value) ? (
<ContactLink
href={`mailto:${value}`}
onClick={(event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
}}
>
{value}
</ContactLink>
) : (
<ContactLink href="#">{value}</ContactLink>
)}
</EllipsisDisplay>
);
@@ -0,0 +1,73 @@
import { MouseEvent } from 'react';
import styled from '@emotion/styled';
import { FieldLinkValue } from '@/object-record/field/types/FieldMetadata';
import { RoundedLink } from '@/ui/navigation/link/components/RoundedLink';
import {
LinkType,
SocialLink,
} from '@/ui/navigation/link/components/SocialLink';
import { EllipsisDisplay } from './EllipsisDisplay';
const StyledRawLink = styled(RoundedLink)`
overflow: hidden;
a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
type LinkDisplayProps = {
value?: FieldLinkValue;
};
const checkUrlType = (url: string) => {
if (
/^(http|https):\/\/(?:www\.)?linkedin.com(\w+:{0,1}\w*@)?(\S+)(:([0-9])+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/.test(
url,
)
) {
return LinkType.LinkedIn;
}
if (url.match(/^((http|https):\/\/)?(?:www\.)?twitter\.com\/(\w+)?/i)) {
return LinkType.Twitter;
}
return LinkType.Url;
};
export const LinkDisplay = ({ value }: LinkDisplayProps) => {
const handleClick = (event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
};
const absoluteUrl = value?.url
? value.url.startsWith('http')
? value.url
: 'https://' + value.url
: '';
const displayedValue = value?.label || value?.url || '';
const type = checkUrlType(absoluteUrl);
if (type === LinkType.LinkedIn || type === LinkType.Twitter) {
return (
<EllipsisDisplay>
<SocialLink href={absoluteUrl} onClick={handleClick} type={type}>
{displayedValue}
</SocialLink>
</EllipsisDisplay>
);
}
return (
<EllipsisDisplay>
<StyledRawLink href={absoluteUrl} onClick={handleClick}>
{displayedValue}
</StyledRawLink>
</EllipsisDisplay>
);
};
@@ -0,0 +1,11 @@
import { formatNumber } from '~/utils/format/number';
import { EllipsisDisplay } from './EllipsisDisplay';
type MoneyDisplayProps = {
value: number | null;
};
export const MoneyDisplay = ({ value }: MoneyDisplayProps) => (
<EllipsisDisplay>{value ? `$${formatNumber(value)}` : ''}</EllipsisDisplay>
);
@@ -0,0 +1,11 @@
import { formatNumber } from '~/utils/format/number';
import { EllipsisDisplay } from './EllipsisDisplay';
type NumberDisplayProps = {
value: string | number | null;
};
export const NumberDisplay = ({ value }: NumberDisplayProps) => (
<EllipsisDisplay>{value && formatNumber(Number(value))}</EllipsisDisplay>
);
@@ -0,0 +1,27 @@
import { MouseEvent } from 'react';
import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js';
import { ContactLink } from '@/ui/navigation/link/components/ContactLink';
import { EllipsisDisplay } from './EllipsisDisplay';
type PhoneDisplayProps = {
value: string | null;
};
export const PhoneDisplay = ({ value }: PhoneDisplayProps) => (
<EllipsisDisplay>
{value && isValidPhoneNumber(value) ? (
<ContactLink
href={parsePhoneNumber(value, 'FR')?.getURI()}
onClick={(event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
}}
>
{parsePhoneNumber(value, 'FR')?.formatInternational() || value}
</ContactLink>
) : (
<ContactLink href="#">{value}</ContactLink>
)}
</EllipsisDisplay>
);
@@ -0,0 +1,9 @@
import { EllipsisDisplay } from './EllipsisDisplay';
type TextDisplayProps = {
text: string;
};
export const TextDisplay = ({ text }: TextDisplayProps) => (
<EllipsisDisplay>{text}</EllipsisDisplay>
);
@@ -0,0 +1,72 @@
import { MouseEvent } from 'react';
import styled from '@emotion/styled';
import { RoundedLink } from '@/ui/navigation/link/components/RoundedLink';
import {
LinkType,
SocialLink,
} from '@/ui/navigation/link/components/SocialLink';
import { EllipsisDisplay } from './EllipsisDisplay';
const StyledRawLink = styled(RoundedLink)`
overflow: hidden;
a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`;
type URLDisplayProps = {
value: string | null;
};
const checkUrlType = (url: string) => {
if (
/^(http|https):\/\/(?:www\.)?linkedin.com(\w+:{0,1}\w*@)?(\S+)(:([0-9])+)?(\/|\/([\w#!:.?+=&%@!\-/]))?/.test(
url,
)
) {
return LinkType.LinkedIn;
}
if (url.match(/^((http|https):\/\/)?(?:www\.)?twitter\.com\/(\w+)?/i)) {
return LinkType.Twitter;
}
return LinkType.Url;
};
export const URLDisplay = ({ value }: URLDisplayProps) => {
const handleClick = (event: MouseEvent<HTMLElement>) => {
event.stopPropagation();
};
const absoluteUrl = value
? value.startsWith('http')
? value
: 'https://' + value
: '';
const displayedValue = value ?? '';
const type = checkUrlType(absoluteUrl);
if (type === LinkType.LinkedIn || type === LinkType.Twitter) {
return (
<EllipsisDisplay>
<SocialLink href={absoluteUrl} onClick={handleClick} type={type}>
{displayedValue}
</SocialLink>
</EllipsisDisplay>
);
}
return (
<EllipsisDisplay>
<StyledRawLink href={absoluteUrl} onClick={handleClick}>
{displayedValue}
</StyledRawLink>
</EllipsisDisplay>
);
};
@@ -0,0 +1,20 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator';
import { PhoneDisplay } from '../PhoneDisplay'; // Adjust the import path as needed
const meta: Meta = {
title: 'UI/Input/PhoneInputDisplay/PhoneInputDisplay',
component: PhoneDisplay,
decorators: [ComponentWithRouterDecorator],
args: {
value: '+33788901234',
},
};
export default meta;
type Story = StoryObj<typeof PhoneDisplay>;
export const Default: Story = {};
@@ -0,0 +1,61 @@
import { useEffect, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCheck, IconX } from '@/ui/display/icon';
const StyledEditableBooleanFieldContainer = styled.div`
align-items: center;
cursor: ${({ onClick }) => (onClick ? 'pointer' : 'default')};
display: flex;
height: 100%;
width: 100%;
`;
const StyledEditableBooleanFieldValue = styled.div`
margin-left: ${({ theme }) => theme.spacing(1)};
`;
type BooleanInputProps = {
value: boolean;
onToggle?: (newValue: boolean) => void;
readonly?: boolean;
testId?: string;
};
export const BooleanInput = ({
value,
onToggle,
readonly,
testId,
}: BooleanInputProps) => {
const [internalValue, setInternalValue] = useState(value);
useEffect(() => {
setInternalValue(value);
}, [value]);
const handleClick = () => {
setInternalValue(!internalValue);
onToggle?.(!internalValue);
};
const theme = useTheme();
return (
<StyledEditableBooleanFieldContainer
onClick={readonly ? undefined : handleClick}
data-testid={testId}
>
{internalValue ? (
<IconCheck size={theme.icon.size.sm} />
) : (
<IconX size={theme.icon.size.sm} />
)}
<StyledEditableBooleanFieldValue>
{internalValue ? 'True' : 'False'}
</StyledEditableBooleanFieldValue>
</StyledEditableBooleanFieldContainer>
);
};
@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { flip, offset, useFloating } from '@floating-ui/react';
import { DateDisplay } from '@/ui/field/display/components/DateDisplay';
import { InternalDatePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker';
import { Nullable } from '~/types/Nullable';
import { useRegisterInputEvents } from '../../../../object-record/field/meta-types/input/hooks/useRegisterInputEvents';
const StyledCalendarContainer = styled.div`
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.light};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
margin-top: 1px;
position: absolute;
z-index: 1;
`;
const StyledInputContainer = styled.div`
padding: ${({ theme }) => theme.spacing(0)} ${({ theme }) => theme.spacing(2)};
width: 100%;
`;
export type DateInputProps = {
value: Nullable<Date>;
onEnter: (newDate: Nullable<Date>) => void;
onEscape: (newDate: Nullable<Date>) => void;
onClickOutside: (
event: MouseEvent | TouchEvent,
newDate: Nullable<Date>,
) => void;
hotkeyScope: string;
};
export const DateInput = ({
value,
hotkeyScope,
onEnter,
onEscape,
onClickOutside,
}: DateInputProps) => {
const theme = useTheme();
const [internalValue, setInternalValue] = useState(value);
const wrapperRef = useRef<HTMLDivElement>(null);
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
middleware: [
flip(),
offset({
mainAxis: theme.spacingMultiplicator * 2,
}),
],
});
const handleChange = (newDate: Date) => {
setInternalValue(newDate);
};
useEffect(() => {
setInternalValue(value);
}, [value]);
useRegisterInputEvents({
inputRef: wrapperRef,
inputValue: internalValue,
onEnter,
onEscape,
onClickOutside,
hotkeyScope,
});
return (
<div ref={wrapperRef}>
<div ref={refs.setReference}>
<StyledInputContainer>
<DateDisplay value={internalValue ?? new Date()} />
</StyledInputContainer>
</div>
<div ref={refs.setFloating} style={floatingStyles}>
<StyledCalendarContainer>
<InternalDatePicker
date={internalValue ?? new Date()}
onChange={handleChange}
onMouseSelect={(newDate: Date) => {
onEnter(newDate);
}}
/>
</StyledCalendarContainer>
</div>
</div>
);
};
@@ -0,0 +1,171 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { Key } from 'ts-key-enum';
import { FieldDoubleText } from '@/object-record/field/types/FieldDoubleText';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { isDefined } from '~/utils/isDefined';
import { StyledInput } from './TextInput';
const StyledContainer = styled.div`
align-items: center;
display: flex;
justify-content: space-between;
input {
width: ${({ theme }) => theme.spacing(24)};
}
& > input:last-child {
border-left: 1px solid ${({ theme }) => theme.border.color.medium};
padding-left: ${({ theme }) => theme.spacing(2)};
}
`;
type DoubleTextInputProps = {
firstValue: string;
secondValue: string;
firstValuePlaceholder: string;
secondValuePlaceholder: string;
hotkeyScope: string;
onEnter: (newDoubleTextValue: FieldDoubleText) => void;
onEscape: (newDoubleTextValue: FieldDoubleText) => void;
onTab?: (newDoubleTextValue: FieldDoubleText) => void;
onShiftTab?: (newDoubleTextValue: FieldDoubleText) => void;
onClickOutside: (
event: MouseEvent | TouchEvent,
newDoubleTextValue: FieldDoubleText,
) => void;
};
export const DoubleTextInput = ({
firstValue,
secondValue,
firstValuePlaceholder,
secondValuePlaceholder,
hotkeyScope,
onClickOutside,
onEnter,
onEscape,
onShiftTab,
onTab,
}: DoubleTextInputProps) => {
const [firstInternalValue, setFirstInternalValue] = useState(firstValue);
const [secondInternalValue, setSecondInternalValue] = useState(secondValue);
const firstValueInputRef = useRef<HTMLInputElement>(null);
const secondValueInputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setFirstInternalValue(firstValue);
setSecondInternalValue(secondValue);
}, [firstValue, secondValue]);
const handleChange = (
newFirstValue: string,
newSecondValue: string,
): void => {
setFirstInternalValue(newFirstValue);
setSecondInternalValue(newSecondValue);
};
const [focusPosition, setFocusPosition] = useState<'left' | 'right'>('left');
useScopedHotkeys(
Key.Enter,
() => {
onEnter({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
},
hotkeyScope,
[onEnter, firstInternalValue, secondInternalValue],
);
useScopedHotkeys(
Key.Escape,
() => {
onEscape({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
},
hotkeyScope,
[onEscape, firstInternalValue, secondInternalValue],
);
useScopedHotkeys(
'tab',
() => {
if (focusPosition === 'left') {
setFocusPosition('right');
secondValueInputRef.current?.focus();
} else {
onTab?.({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
}
},
hotkeyScope,
[onTab, firstInternalValue, secondInternalValue, focusPosition],
);
useScopedHotkeys(
'shift+tab',
() => {
if (focusPosition === 'right') {
setFocusPosition('left');
firstValueInputRef.current?.focus();
} else {
onShiftTab?.({
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
}
},
hotkeyScope,
[onShiftTab, firstInternalValue, secondInternalValue, focusPosition],
);
useListenClickOutside({
refs: [containerRef],
callback: (event) => {
onClickOutside?.(event, {
firstValue: firstInternalValue,
secondValue: secondInternalValue,
});
},
enabled: isDefined(onClickOutside),
});
return (
<StyledContainer ref={containerRef}>
<StyledInput
autoComplete="off"
autoFocus
onFocus={() => setFocusPosition('left')}
ref={firstValueInputRef}
placeholder={firstValuePlaceholder}
value={firstInternalValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
handleChange(event.target.value, secondInternalValue);
}}
/>
<StyledInput
autoComplete="off"
onFocus={() => setFocusPosition('right')}
ref={secondValueInputRef}
placeholder={secondValuePlaceholder}
value={secondInternalValue}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
handleChange(firstInternalValue, event.target.value);
}}
/>
</StyledContainer>
);
};
@@ -0,0 +1,15 @@
import styled from '@emotion/styled';
import { overlayBackground } from '@/ui/theme/constants/effects';
const StyledFieldInputOverlay = styled.div`
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
border-radius: ${({ theme }) => theme.border.radius.sm};
${overlayBackground}
display: flex;
height: 32px;
margin: -1px;
width: 100%;
`;
export const FieldInputOverlay = StyledFieldInputOverlay;
@@ -0,0 +1,102 @@
import { useEffect, useRef, useState } from 'react';
import ReactPhoneNumberInput from 'react-phone-number-input';
import styled from '@emotion/styled';
import { CountryPickerDropdownButton } from '@/ui/input/components/internal/phone/components/CountryPickerDropdownButton';
import { useRegisterInputEvents } from '../../../../object-record/field/meta-types/input/hooks/useRegisterInputEvents';
import 'react-phone-number-input/style.css';
const StyledContainer = styled.div`
align-items: center;
border: none;
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
justify-content: center;
`;
const StyledCustomPhoneInput = styled(ReactPhoneNumberInput)`
font-family: ${({ theme }) => theme.font.family};
height: 32px;
.PhoneInputInput {
background: ${({ theme }) => theme.background.transparent.secondary};
border: none;
color: ${({ theme }) => theme.font.color.primary};
&::placeholder,
&::-webkit-input-placeholder {
color: ${({ theme }) => theme.font.color.light};
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.medium};
}
:focus {
outline: none;
}
}
& svg {
border-radius: ${({ theme }) => theme.border.radius.xs};
height: 12px;
}
`;
export type PhoneInputProps = {
placeholder?: string;
autoFocus?: boolean;
value: string;
onEnter: (newText: string) => void;
onEscape: (newText: string) => void;
onTab?: (newText: string) => void;
onShiftTab?: (newText: string) => void;
onClickOutside: (event: MouseEvent | TouchEvent, inputValue: string) => void;
hotkeyScope: string;
};
export const PhoneInput = ({
autoFocus,
value,
onEnter,
onEscape,
onTab,
onShiftTab,
onClickOutside,
hotkeyScope,
}: PhoneInputProps) => {
const [internalValue, setInternalValue] = useState<string | undefined>(value);
const wrapperRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setInternalValue(value);
}, [value]);
useRegisterInputEvents({
inputRef: wrapperRef,
inputValue: internalValue ?? '',
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
hotkeyScope,
});
return (
<StyledContainer ref={wrapperRef}>
<StyledCustomPhoneInput
autoFocus={autoFocus}
placeholder="Phone number"
value={value}
onChange={setInternalValue}
international={true}
withCountryCallingCode={true}
countrySelectComponent={CountryPickerDropdownButton}
/>
</StyledContainer>
);
};
@@ -0,0 +1,61 @@
import { useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconTwentyStarFilled } from '@/ui/display/icon/components/IconTwentyStarFilled';
const StyledContainer = styled.div`
align-items: center;
display: flex;
`;
const StyledRatingIconContainer = styled.div<{ isActive?: boolean }>`
color: ${({ isActive, theme }) =>
isActive ? theme.font.color.secondary : theme.background.quaternary};
display: inline-flex;
`;
type RatingInputProps = {
onChange: (newValue: number) => void;
value: number;
readonly?: boolean;
};
const RATING_LEVELS_NB = 5;
export const RatingInput = ({
onChange,
value,
readonly,
}: RatingInputProps) => {
const theme = useTheme();
const [hoveredValue, setHoveredValue] = useState<number | null>(null);
const currentValue = hoveredValue ?? value;
return (
<StyledContainer
role="slider"
aria-label="Rating"
aria-valuemax={RATING_LEVELS_NB}
aria-valuemin={1}
aria-valuenow={value}
tabIndex={0}
>
{Array.from({ length: RATING_LEVELS_NB }, (_, index) => {
const rating = index + 1;
return (
<StyledRatingIconContainer
key={index}
isActive={rating <= currentValue}
onClick={readonly ? undefined : () => onChange(rating)}
onMouseEnter={readonly ? undefined : () => setHoveredValue(rating)}
onMouseLeave={readonly ? undefined : () => setHoveredValue(null)}
>
<IconTwentyStarFilled size={theme.icon.size.md} />
</StyledRatingIconContainer>
);
})}
</StyledContainer>
);
};
@@ -0,0 +1,70 @@
import { ChangeEvent, useEffect, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/theme/constants/effects';
import { useRegisterInputEvents } from '../../../../object-record/field/meta-types/input/hooks/useRegisterInputEvents';
export const StyledInput = styled.input`
margin: 0;
${textInputStyle}
width: 100%;
`;
type TextInputProps = {
placeholder?: string;
autoFocus?: boolean;
value: string;
onEnter: (newText: string) => void;
onEscape: (newText: string) => void;
onTab?: (newText: string) => void;
onShiftTab?: (newText: string) => void;
onClickOutside: (event: MouseEvent | TouchEvent, inputValue: string) => void;
hotkeyScope: string;
};
export const TextInput = ({
placeholder,
autoFocus,
value,
hotkeyScope,
onEnter,
onEscape,
onTab,
onShiftTab,
onClickOutside,
}: TextInputProps) => {
const [internalText, setInternalText] = useState(value);
const wrapperRef = useRef<HTMLInputElement>(null);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setInternalText(event.target.value);
};
useEffect(() => {
setInternalText(value);
}, [value]);
useRegisterInputEvents({
inputRef: wrapperRef,
inputValue: internalText,
onEnter,
onEscape,
onClickOutside,
onTab,
onShiftTab,
hotkeyScope,
});
return (
<StyledInput
autoComplete="off"
ref={wrapperRef}
placeholder={placeholder}
onChange={handleChange}
autoFocus={autoFocus}
value={internalText}
/>
);
};
@@ -0,0 +1,317 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { SoonPill } from '@/ui/display/pill/components/SoonPill';
export type ButtonSize = 'medium' | 'small';
export type ButtonPosition = 'standalone' | 'left' | 'middle' | 'right';
export type ButtonVariant = 'primary' | 'secondary' | 'tertiary';
export type ButtonAccent = 'default' | 'blue' | 'danger';
export type ButtonProps = {
className?: string;
Icon?: IconComponent;
title?: string;
fullWidth?: boolean;
variant?: ButtonVariant;
size?: ButtonSize;
position?: ButtonPosition;
accent?: ButtonAccent;
soon?: boolean;
disabled?: boolean;
focus?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
};
const StyledButton = styled.button<
Pick<
ButtonProps,
'fullWidth' | 'variant' | 'size' | 'position' | 'accent' | 'focus'
>
>`
align-items: center;
${({ theme, variant, accent, disabled, focus }) => {
switch (variant) {
case 'primary':
switch (accent) {
case 'default':
return `
background: ${theme.background.secondary};
border-color: ${
!disabled
? focus
? theme.color.blue
: theme.background.transparent.light
: 'transparent'
};
color: ${
!disabled
? theme.font.color.secondary
: theme.font.color.extraLight
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
&:hover {
background: ${
!disabled
? theme.background.tertiary
: theme.background.secondary
};
}
&:active {
background: ${
!disabled
? theme.background.quaternary
: theme.background.secondary
};
}
`;
case 'blue':
return `
background: ${!disabled ? theme.color.blue : theme.color.blue20};
border-color: ${
!disabled
? focus
? theme.color.blue
: theme.background.transparent.light
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
color: ${theme.grayScale.gray0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
&:hover {
background: ${
!disabled ? theme.color.blue50 : theme.color.blue20
};
}
&:active {
background: ${
!disabled ? theme.color.blue60 : theme.color.blue20
};
}
`;
case 'danger':
return `
background: ${!disabled ? theme.color.red : theme.color.red20};
border-color: ${
!disabled
? focus
? theme.color.red
: theme.background.transparent.light
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus ? `0 0 0 3px ${theme.color.red10}` : 'none'
};
color: ${theme.grayScale.gray0};
&:hover {
background: ${
!disabled ? theme.color.red50 : theme.color.red20
};
}
&:active {
background: ${
!disabled ? theme.color.red50 : theme.color.red20
};
}
`;
}
break;
case 'secondary':
case 'tertiary':
switch (accent) {
case 'default':
return `
background: ${
focus ? theme.background.transparent.primary : 'transparent'
};
border-color: ${
variant === 'secondary'
? !disabled && focus
? theme.color.blue
: theme.background.transparent.light
: focus
? theme.color.blue
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
color: ${
!disabled
? theme.font.color.secondary
: theme.font.color.extraLight
};
&:hover {
background: ${
!disabled ? theme.background.transparent.light : 'transparent'
};
}
&:active {
background: ${
!disabled ? theme.background.transparent.light : 'transparent'
};
}
`;
case 'blue':
return `
background: ${
focus ? theme.background.transparent.primary : 'transparent'
};
border-color: ${
variant === 'secondary'
? focus
? theme.color.blue
: theme.color.blue20
: focus
? theme.color.blue
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
color: ${!disabled ? theme.color.blue : theme.accent.accent4060};
&:hover {
background: ${
!disabled ? theme.accent.tertiary : 'transparent'
};
}
&:active {
background: ${
!disabled ? theme.accent.secondary : 'transparent'
};
}
`;
case 'danger':
return `
background: ${
!disabled ? theme.background.transparent.primary : 'transparent'
};
border-color: ${
variant === 'secondary'
? focus
? theme.color.red
: theme.border.color.danger
: focus
? theme.color.red
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus ? `0 0 0 3px ${theme.color.red10}` : 'none'
};
color: ${!disabled ? theme.font.color.danger : theme.color.red20};
&:hover {
background: ${
!disabled ? theme.background.danger : 'transparent'
};
}
&:active {
background: ${
!disabled ? theme.background.danger : 'transparent'
};
}
`;
}
}
}}
border-radius: ${({ position, theme }) => {
switch (position) {
case 'left':
return `${theme.border.radius.sm} 0px 0px ${theme.border.radius.sm}`;
case 'right':
return `0px ${theme.border.radius.sm} ${theme.border.radius.sm} 0px`;
case 'middle':
return '0px';
case 'standalone':
return theme.border.radius.sm;
}
}};
border-style: solid;
border-width: ${({ variant, position }) => {
switch (variant) {
case 'primary':
case 'secondary':
return position === 'middle' ? '1px 0px' : '1px';
case 'tertiary':
return '0';
}
}};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-weight: 500;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ size }) => (size === 'small' ? '24px' : '32px')};
padding: ${({ theme }) => {
return `0 ${theme.spacing(2)}`;
}};
transition: background 0.1s ease;
white-space: nowrap;
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
&:focus {
outline: none;
}
`;
const StyledSoonPill = styled(SoonPill)`
margin-left: auto;
`;
export const Button = ({
className,
Icon,
title,
fullWidth = false,
variant = 'primary',
size = 'medium',
accent = 'default',
position = 'standalone',
soon = false,
disabled = false,
focus = false,
onClick,
}: ButtonProps) => {
const theme = useTheme();
return (
<StyledButton
fullWidth={fullWidth}
variant={variant}
size={size}
position={position}
disabled={soon || disabled}
focus={focus}
accent={accent}
className={className}
onClick={onClick}
>
{Icon && <Icon size={theme.icon.size.sm} />}
{title}
{soon && <StyledSoonPill />}
</StyledButton>
);
};
@@ -0,0 +1,57 @@
import React, { ReactNode } from 'react';
import styled from '@emotion/styled';
import { ButtonPosition, ButtonProps } from './Button';
const StyledButtonGroupContainer = styled.div`
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
`;
export type ButtonGroupProps = Pick<
ButtonProps,
'variant' | 'size' | 'accent'
> & {
className?: string;
children: ReactNode[];
};
export const ButtonGroup = ({
className,
children,
variant,
size,
accent,
}: ButtonGroupProps) => (
<StyledButtonGroupContainer className={className}>
{React.Children.map(children, (child, index) => {
if (!React.isValidElement(child)) return null;
let position: ButtonPosition;
if (index === 0) {
position = 'left';
} else if (index === children.length - 1) {
position = 'right';
} else {
position = 'middle';
}
const additionalProps: any = { position, variant, accent, size };
if (variant) {
additionalProps.variant = variant;
}
if (accent) {
additionalProps.variant = variant;
}
if (size) {
additionalProps.size = size;
}
return React.cloneElement(child, additionalProps);
})}
</StyledButtonGroupContainer>
);
@@ -0,0 +1,106 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
export type FloatingButtonSize = 'small' | 'medium';
export type FloatingButtonPosition = 'standalone' | 'left' | 'middle' | 'right';
export type FloatingButtonProps = {
className?: string;
Icon?: IconComponent;
title?: string;
size?: FloatingButtonSize;
position?: FloatingButtonPosition;
applyShadow?: boolean;
applyBlur?: boolean;
disabled?: boolean;
focus?: boolean;
};
const StyledButton = styled.button<
Pick<
FloatingButtonProps,
'size' | 'focus' | 'position' | 'applyBlur' | 'applyShadow'
>
>`
align-items: center;
backdrop-filter: ${({ applyBlur }) => (applyBlur ? 'blur(20px)' : 'none')};
background: ${({ theme }) => theme.background.primary};
border: ${({ focus, theme }) =>
focus ? `1px solid ${theme.color.blue}` : 'none'};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: ${({ theme, applyShadow, focus }) =>
applyShadow
? `0px 2px 4px 0px ${
theme.background.transparent.light
}, 0px 0px 4px 0px ${theme.background.transparent.medium}${
focus ? `,0 0 0 3px ${theme.color.blue10}` : ''
}`
: focus
? `0 0 0 3px ${theme.color.blue10}`
: 'none'};
color: ${({ theme, disabled, focus }) => {
return !disabled
? focus
? theme.color.blue
: theme.font.color.secondary
: theme.font.color.extraLight;
}};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ size }) => (size === 'small' ? '24px' : '32px')};
padding: ${({ theme }) => {
return `0 ${theme.spacing(2)}`;
}};
transition: background 0.1s ease;
white-space: nowrap;
&:hover {
background: ${({ theme, disabled }) =>
!disabled ? theme.background.transparent.lighter : 'transparent'};
}
&:active {
background: ${({ theme, disabled }) =>
!disabled ? theme.background.transparent.medium : 'transparent'};
}
&:focus {
outline: none;
}
`;
export const FloatingButton = ({
className,
Icon,
title,
size = 'small',
applyBlur = true,
applyShadow = true,
disabled = false,
focus = false,
}: FloatingButtonProps) => {
const theme = useTheme();
return (
<StyledButton
disabled={disabled}
focus={focus && !disabled}
size={size}
applyBlur={applyBlur}
applyShadow={applyShadow}
className={className}
>
{Icon && <Icon size={theme.icon.size.sm} />}
{title}
</StyledButton>
);
};
@@ -0,0 +1,50 @@
import React from 'react';
import styled from '@emotion/styled';
import { FloatingButtonPosition, FloatingButtonProps } from './FloatingButton';
const StyledFloatingButtonGroupContainer = styled.div`
backdrop-filter: blur(20px);
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) =>
`0px 2px 4px 0px ${theme.background.transparent.light}, 0px 0px 4px 0px ${theme.background.transparent.medium}`};
display: inline-flex;
`;
export type FloatingButtonGroupProps = Pick<FloatingButtonProps, 'size'> & {
children: React.ReactElement[];
className?: string;
};
export const FloatingButtonGroup = ({
children,
size,
className,
}: FloatingButtonGroupProps) => (
<StyledFloatingButtonGroupContainer className={className}>
{React.Children.map(children, (child, index) => {
let position: FloatingButtonPosition;
if (index === 0) {
position = 'left';
} else if (index === children.length - 1) {
position = 'right';
} else {
position = 'middle';
}
const additionalProps: any = {
position,
size,
applyShadow: false,
applyBlur: false,
};
if (size) {
additionalProps.size = size;
}
return React.cloneElement(child, additionalProps);
})}
</StyledFloatingButtonGroupContainer>
);
@@ -0,0 +1,134 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
export type FloatingIconButtonSize = 'small' | 'medium';
export type FloatingIconButtonPosition =
| 'standalone'
| 'left'
| 'middle'
| 'right';
export type FloatingIconButtonProps = {
className?: string;
Icon?: IconComponent;
size?: FloatingIconButtonSize;
position?: FloatingIconButtonPosition;
applyShadow?: boolean;
applyBlur?: boolean;
disabled?: boolean;
focus?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
isActive?: boolean;
};
const StyledButton = styled.button<
Pick<
FloatingIconButtonProps,
'size' | 'position' | 'applyShadow' | 'applyBlur' | 'focus' | 'isActive'
>
>`
align-items: center;
backdrop-filter: ${({ applyBlur }) => (applyBlur ? 'blur(20px)' : 'none')};
background: ${({ theme, isActive }) =>
isActive ? theme.background.transparent.medium : theme.background.primary};
border: ${({ focus, theme }) =>
focus ? `1px solid ${theme.color.blue}` : 'transparent'};
border-radius: ${({ position, theme }) => {
switch (position) {
case 'left':
return `${theme.border.radius.sm} 0px 0px ${theme.border.radius.sm}`;
case 'right':
return `0px ${theme.border.radius.sm} ${theme.border.radius.sm} 0px`;
case 'middle':
return '0px';
case 'standalone':
return theme.border.radius.sm;
}
}};
box-shadow: ${({ theme, applyShadow, focus }) =>
applyShadow
? `0px 2px 4px ${theme.background.transparent.light}, 0px 0px 4px ${
theme.background.transparent.medium
}${focus ? `,0 0 0 3px ${theme.color.blue10}` : ''}`
: focus
? `0 0 0 3px ${theme.color.blue10}`
: 'none'};
box-sizing: border-box;
color: ${({ theme, disabled, focus }) => {
return !disabled
? focus
? theme.color.blue
: theme.font.color.tertiary
: theme.font.color.extraLight;
}};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
gap: ${({ theme }) => theme.spacing(1)};
justify-content: center;
padding: 0;
position: relative;
transition: background ${({ theme }) => theme.animation.duration.instant}s
ease;
white-space: nowrap;
${({ position, size }) => {
const sizeInPx =
(size === 'small' ? 24 : 32) - (position === 'standalone' ? 0 : 4);
return `
height: ${sizeInPx}px;
width: ${sizeInPx}px;
`;
}}
&:hover {
background: ${({ theme, isActive }) =>
!!isActive ?? theme.background.transparent.lighter};
}
&:active {
background: ${({ theme, disabled }) =>
!disabled ? theme.background.transparent.medium : 'transparent'};
}
&:focus {
outline: none;
}
`;
export const FloatingIconButton = ({
className,
Icon,
size = 'small',
position = 'standalone',
applyShadow = true,
applyBlur = true,
disabled = false,
focus = false,
onClick,
isActive,
}: FloatingIconButtonProps) => {
const theme = useTheme();
return (
<StyledButton
disabled={disabled}
focus={focus && !disabled}
size={size}
applyShadow={applyShadow}
applyBlur={applyBlur}
className={className}
position={position}
onClick={onClick}
isActive={isActive}
>
{Icon && <Icon size={theme.icon.size.md} />}
</StyledButton>
);
};
@@ -0,0 +1,64 @@
import { MouseEvent } from 'react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import {
FloatingIconButton,
FloatingIconButtonPosition,
FloatingIconButtonProps,
} from './FloatingIconButton';
const StyledFloatingIconButtonGroupContainer = styled.div`
backdrop-filter: blur(20px);
background-color: ${({ theme }) => theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: ${({ theme }) =>
`0px 2px 4px 0px ${theme.background.transparent.light}, 0px 0px 4px 0px ${theme.background.transparent.medium}`};
display: inline-flex;
gap: 2px;
padding: 2px;
`;
export type FloatingIconButtonGroupProps = Pick<
FloatingIconButtonProps,
'className' | 'size'
> & {
iconButtons: {
Icon: IconComponent;
onClick?: (event: MouseEvent<any>) => void;
isActive?: boolean;
}[];
};
export const FloatingIconButtonGroup = ({
iconButtons,
size,
className,
}: FloatingIconButtonGroupProps) => (
<StyledFloatingIconButtonGroupContainer className={className}>
{iconButtons.map(({ Icon, onClick, isActive }, index) => {
const position: FloatingIconButtonPosition =
iconButtons.length === 1
? 'standalone'
: index === 0
? 'left'
: index === iconButtons.length - 1
? 'right'
: 'middle';
return (
<FloatingIconButton
key={`floating-icon-button-${index}`}
applyBlur={false}
applyShadow={false}
Icon={Icon}
onClick={onClick}
position={position}
size={size}
isActive={isActive}
/>
);
})}
</StyledFloatingIconButtonGroupContainer>
);
@@ -0,0 +1,300 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
export type IconButtonSize = 'medium' | 'small';
export type IconButtonPosition = 'standalone' | 'left' | 'middle' | 'right';
export type IconButtonVariant = 'primary' | 'secondary' | 'tertiary';
export type IconButtonAccent = 'default' | 'blue' | 'danger';
export type IconButtonProps = {
className?: string;
Icon?: IconComponent;
variant?: IconButtonVariant;
size?: IconButtonSize;
position?: IconButtonPosition;
accent?: IconButtonAccent;
disabled?: boolean;
focus?: boolean;
dataTestId?: string;
ariaLabel?: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
};
const StyledButton = styled.button<
Pick<IconButtonProps, 'variant' | 'size' | 'position' | 'accent' | 'focus'>
>`
align-items: center;
${({ theme, variant, accent, disabled, focus }) => {
switch (variant) {
case 'primary':
switch (accent) {
case 'default':
return `
background: ${theme.background.secondary};
border-color: ${
!disabled
? focus
? theme.color.blue
: theme.background.transparent.light
: 'transparent'
};
color: ${
!disabled
? theme.font.color.secondary
: theme.font.color.extraLight
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
&:hover {
background: ${
!disabled
? theme.background.tertiary
: theme.background.secondary
};
}
&:active {
background: ${
!disabled
? theme.background.quaternary
: theme.background.secondary
};
}
`;
case 'blue':
return `
background: ${!disabled ? theme.color.blue : theme.color.blue20};
border-color: ${
!disabled
? focus
? theme.color.blue
: theme.background.transparent.light
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
color: ${theme.grayScale.gray0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
&:hover {
background: ${
!disabled ? theme.color.blue50 : theme.color.blue20
};
}
&:active {
background: ${
!disabled ? theme.color.blue60 : theme.color.blue20
};
}
`;
case 'danger':
return `
background: ${!disabled ? theme.color.red : theme.color.red20};
border-color: ${
!disabled
? focus
? theme.color.red
: theme.background.transparent.light
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus ? `0 0 0 3px ${theme.color.red10}` : 'none'
};
color: ${theme.grayScale.gray0};
&:hover {
background: ${
!disabled ? theme.color.red50 : theme.color.red20
};
}
&:active {
background: ${
!disabled ? theme.color.red50 : theme.color.red20
};
}
`;
}
break;
case 'secondary':
case 'tertiary':
switch (accent) {
case 'default':
return `
background: ${
focus ? theme.background.transparent.primary : 'transparent'
};
border-color: ${
variant === 'secondary'
? !disabled && focus
? theme.color.blue
: theme.background.transparent.light
: focus
? theme.color.blue
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
color: ${
!disabled
? theme.font.color.secondary
: theme.font.color.extraLight
};
&:hover {
background: ${
!disabled ? theme.background.transparent.light : 'transparent'
};
}
&:active {
background: ${
!disabled ? theme.background.transparent.light : 'transparent'
};
}
`;
case 'blue':
return `
background: ${
focus ? theme.background.transparent.primary : 'transparent'
};
border-color: ${
variant === 'secondary'
? !disabled
? theme.color.blue
: theme.color.blue20
: focus
? theme.color.blue
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus
? `0 0 0 3px ${theme.accent.tertiary}`
: 'none'
};
color: ${!disabled ? theme.color.blue : theme.accent.accent4060};
&:hover {
background: ${
!disabled ? theme.accent.tertiary : 'transparent'
};
}
&:active {
background: ${
!disabled ? theme.accent.secondary : 'transparent'
};
}
`;
case 'danger':
return `
background: transparent;
border-color: ${
variant === 'secondary'
? theme.border.color.danger
: focus
? theme.color.red
: 'transparent'
};
border-width: ${!disabled && focus ? '1px 1px !important' : 0};
box-shadow: ${
!disabled && focus ? `0 0 0 3px ${theme.color.red10}` : 'none'
};
color: ${!disabled ? theme.font.color.danger : theme.color.red20};
&:hover {
background: ${
!disabled ? theme.background.danger : 'transparent'
};
}
&:active {
background: ${
!disabled ? theme.background.danger : 'transparent'
};
}
`;
}
}
}}
border-radius: ${({ position, theme }) => {
switch (position) {
case 'left':
return `${theme.border.radius.sm} 0px 0px ${theme.border.radius.sm}`;
case 'right':
return `0px ${theme.border.radius.sm} ${theme.border.radius.sm} 0px`;
case 'middle':
return '0px';
case 'standalone':
return theme.border.radius.sm;
}
}};
border-style: solid;
border-width: ${({ variant, position }) => {
switch (variant) {
case 'primary':
case 'secondary':
return position === 'middle' ? '1px 0px' : '1px';
case 'tertiary':
return '0';
}
}};
box-sizing: content-box;
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-weight: 500;
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ size }) => (size === 'small' ? '24px' : '32px')};
justify-content: center;
padding: 0;
transition: background 0.1s ease;
white-space: nowrap;
width: ${({ size }) => (size === 'small' ? '24px' : '32px')};
&:focus {
outline: none;
}
`;
export const IconButton = ({
className,
Icon,
variant = 'primary',
size = 'medium',
accent = 'default',
position = 'standalone',
disabled = false,
focus = false,
dataTestId,
ariaLabel,
onClick,
}: IconButtonProps) => {
const theme = useTheme();
return (
<StyledButton
data-testid={dataTestId}
variant={variant}
size={size}
position={position}
disabled={disabled}
focus={focus}
accent={accent}
className={className}
onClick={onClick}
aria-label={ariaLabel}
>
{Icon && <Icon size={theme.icon.size.md} />}
</StyledButton>
);
};
@@ -0,0 +1,52 @@
import { MouseEvent } from 'react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
import { IconButton, IconButtonPosition, IconButtonProps } from './IconButton';
const StyledIconButtonGroupContainer = styled.div`
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
`;
export type IconButtonGroupProps = Pick<
IconButtonProps,
'accent' | 'size' | 'variant'
> & {
iconButtons: {
Icon: IconComponent;
onClick?: (event: MouseEvent<any>) => void;
}[];
className?: string;
};
export const IconButtonGroup = ({
accent,
iconButtons,
size,
variant,
className,
}: IconButtonGroupProps) => (
<StyledIconButtonGroupContainer className={className}>
{iconButtons.map(({ Icon, onClick }, index) => {
const position: IconButtonPosition =
index === 0
? 'left'
: index === iconButtons.length - 1
? 'right'
: 'middle';
return (
<IconButton
accent={accent}
Icon={Icon}
onClick={onClick}
position={position}
size={size}
variant={variant}
/>
);
})}
</StyledIconButtonGroupContainer>
);
@@ -0,0 +1,110 @@
import React, { MouseEvent, useMemo } from 'react';
import styled from '@emotion/styled';
import { TablerIconsProps } from '@/ui/display/icon';
export type LightButtonAccent = 'secondary' | 'tertiary';
export type LightButtonProps = {
className?: string;
icon?: React.ReactNode;
title?: string;
accent?: LightButtonAccent;
active?: boolean;
disabled?: boolean;
focus?: boolean;
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
};
const StyledButton = styled.button<
Pick<LightButtonProps, 'accent' | 'active' | 'focus'>
>`
align-items: center;
background: transparent;
border: ${({ theme, focus }) =>
focus ? `1px solid ${theme.color.blue}` : 'none'};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: ${({ theme, focus }) =>
focus ? `0 0 0 3px ${theme.color.blue10}` : 'none'};
color: ${({ theme, accent, active, disabled, focus }) => {
switch (accent) {
case 'secondary':
return active || focus
? theme.color.blue
: !disabled
? theme.font.color.secondary
: theme.font.color.extraLight;
case 'tertiary':
return active || focus
? theme.color.blue
: !disabled
? theme.font.color.tertiary
: theme.font.color.extraLight;
}
}};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
gap: ${({ theme }) => theme.spacing(1)};
height: 24px;
padding: ${({ theme }) => {
return `0 ${theme.spacing(2)}`;
}};
transition: background 0.1s ease;
white-space: nowrap;
&:hover {
background: ${({ theme, disabled }) =>
!disabled ? theme.background.transparent.light : 'transparent'};
}
&:focus {
outline: none;
}
&:active {
background: ${({ theme, disabled }) =>
!disabled ? theme.background.transparent.medium : 'transparent'};
}
`;
export const LightButton = ({
className,
icon: initialIcon,
title,
active = false,
accent = 'secondary',
disabled = false,
focus = false,
onClick,
}: LightButtonProps) => {
const icon = useMemo(() => {
if (!initialIcon || !React.isValidElement(initialIcon)) {
return null;
}
return React.cloneElement<TablerIconsProps>(initialIcon as any, {
size: 14,
});
}, [initialIcon]);
return (
<StyledButton
onClick={onClick}
disabled={disabled}
focus={focus && !disabled}
accent={accent}
className={className}
active={active}
>
{icon}
{title}
</StyledButton>
);
};
@@ -0,0 +1,112 @@
import { ComponentProps, MouseEvent } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
export type LightIconButtonAccent = 'secondary' | 'tertiary';
export type LightIconButtonSize = 'small' | 'medium';
export type LightIconButtonProps = {
className?: string;
testId?: string;
Icon?: IconComponent;
title?: string;
size?: LightIconButtonSize;
accent?: LightIconButtonAccent;
active?: boolean;
disabled?: boolean;
focus?: boolean;
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
} & Pick<ComponentProps<'button'>, 'aria-label' | 'title'>;
const StyledButton = styled.button<
Pick<LightIconButtonProps, 'accent' | 'active' | 'size' | 'focus'>
>`
align-items: center;
background: transparent;
border: none;
border: ${({ disabled, theme, focus }) =>
!disabled && focus ? `1px solid ${theme.color.blue}` : 'none'};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: ${({ disabled, theme, focus }) =>
!disabled && focus ? `0 0 0 3px ${theme.color.blue10}` : 'none'};
color: ${({ theme, accent, active, disabled, focus }) => {
switch (accent) {
case 'secondary':
return active || focus
? theme.color.blue
: !disabled
? theme.font.color.secondary
: theme.font.color.extraLight;
case 'tertiary':
return active || focus
? theme.color.blue
: !disabled
? theme.font.color.tertiary
: theme.font.color.extraLight;
}
}};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
gap: ${({ theme }) => theme.spacing(1)};
height: ${({ size }) => (size === 'small' ? '24px' : '32px')};
justify-content: center;
padding: 0;
transition: background 0.1s ease;
white-space: nowrap;
width: ${({ size }) => (size === 'small' ? '24px' : '32px')};
&:hover {
background: ${({ theme, disabled }) =>
!disabled ? theme.background.transparent.light : 'transparent'};
}
&:focus {
outline: none;
}
&:active {
background: ${({ theme, disabled }) =>
!disabled ? theme.background.transparent.medium : 'transparent'};
}
`;
export const LightIconButton = ({
'aria-label': ariaLabel,
className,
testId,
Icon,
active = false,
size = 'small',
accent = 'secondary',
disabled = false,
focus = false,
onClick,
title,
}: LightIconButtonProps) => {
const theme = useTheme();
return (
<StyledButton
data-testid={testId}
aria-label={ariaLabel}
onClick={onClick}
disabled={disabled}
focus={focus && !disabled}
accent={accent}
className={className}
size={size}
active={active}
title={title}
>
{Icon && <Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />}
</StyledButton>
);
};
@@ -0,0 +1,121 @@
import React from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
type Variant = 'primary' | 'secondary';
type Props = {
title: string;
fullWidth?: boolean;
variant?: Variant;
soon?: boolean;
} & React.ComponentProps<'button'>;
const StyledButton = styled.button<Pick<Props, 'fullWidth' | 'variant'>>`
align-items: center;
background: ${({ theme, variant, disabled }) => {
if (disabled) {
return theme.background.secondary;
}
switch (variant) {
case 'primary':
return theme.background.radialGradient;
case 'secondary':
return theme.background.primary;
default:
return theme.background.primary;
}
}};
border: 1px solid;
border-color: ${({ theme, disabled, variant }) => {
if (disabled) {
return theme.background.transparent.lighter;
}
switch (variant) {
case 'primary':
return theme.background.transparent.light;
case 'secondary':
return theme.border.color.medium;
default:
return theme.background.primary;
}
}};
border-radius: ${({ theme }) => theme.border.radius.md};
${({ theme, disabled }) => {
if (disabled) {
return '';
}
return `box-shadow: ${theme.boxShadow.light};`;
}}
color: ${({ theme, variant, disabled }) => {
if (disabled) {
return theme.font.color.light;
}
switch (variant) {
case 'primary':
return theme.grayScale.gray0;
case 'secondary':
return theme.font.color.primary;
default:
return theme.font.color.primary;
}
}};
cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')};
display: flex;
flex-direction: row;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
gap: ${({ theme }) => theme.spacing(2)};
justify-content: center;
outline: none;
padding: ${({ theme }) => theme.spacing(2)} ${({ theme }) => theme.spacing(3)};
width: ${({ fullWidth }) => (fullWidth ? '100%' : 'auto')};
${({ theme, variant }) => {
switch (variant) {
case 'secondary':
return `
&:hover {
background: ${theme.background.tertiary};
}
`;
default:
return `
&:hover {
background: ${theme.background.radialGradientHover}};
}
`;
}
}};
`;
type MainButtonProps = Props & {
Icon?: IconComponent;
};
export const MainButton = ({
Icon,
title,
fullWidth = false,
variant = 'primary',
type,
onClick,
disabled,
className,
}: MainButtonProps) => {
const theme = useTheme();
return (
<StyledButton
className={className}
{...{ disabled, fullWidth, onClick, type, variant }}
>
{Icon && <Icon size={theme.icon.size.sm} />}
{title}
</StyledButton>
);
};
@@ -0,0 +1,51 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconComponent } from '@/ui/display/icon/types/IconComponent';
const StyledIconButton = styled.button`
align-items: center;
background: ${({ theme }) => theme.color.blue};
border: none;
border-radius: 50%;
color: ${({ theme }) => theme.font.color.inverted};
cursor: pointer;
display: flex;
height: 20px;
justify-content: center;
outline: none;
padding: 0;
transition:
color 0.1s ease-in-out,
background 0.1s ease-in-out;
&:disabled {
background: ${({ theme }) => theme.background.quaternary};
color: ${({ theme }) => theme.font.color.tertiary};
cursor: default;
}
width: 20px;
`;
type RoundedIconButtonProps = {
Icon: IconComponent;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
export const RoundedIconButton = ({
Icon,
onClick,
disabled,
className,
}: RoundedIconButtonProps) => {
const theme = useTheme();
return (
<StyledIconButton className={className} {...{ disabled, onClick }}>
{<Icon size={theme.icon.size.md} />}
</StyledIconButton>
);
};
@@ -0,0 +1,28 @@
{/* Button.mdx */}
import { Meta, Controls, Story } from '@storybook/blocks';
import * as ButtonStories from './Button.stories';
<Meta of={ButtonStories} />
Button is a clickable interactive element that triggers a response.
You can place text and icons inside of a button.
Buttons are often used for form submissions and to toggle elements into view.
<Story of={ButtonStories.Default} />
<br />
## Properties
<Controls />
## Usage
```js
import { Button } from '@/ui/button/components/Button';
<Button title='Click me' />
```
@@ -0,0 +1,279 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconSearch } from '@/ui/display/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import {
Button,
ButtonAccent,
ButtonPosition,
ButtonSize,
ButtonVariant,
} from '../Button';
const meta: Meta<typeof Button> = {
title: 'UI/Input/Button/Button',
component: Button,
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Default: Story = {
argTypes: {
Icon: { control: false },
},
args: {
title: 'Button',
size: 'small',
variant: 'primary',
accent: 'danger',
disabled: false,
focus: false,
fullWidth: false,
soon: false,
position: 'standalone',
Icon: IconSearch,
className: '',
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof Button> = {
args: { title: 'Filter', Icon: IconSearch },
argTypes: {
size: { control: false },
variant: { control: false },
accent: { control: false },
disabled: { control: false },
focus: { control: false },
fullWidth: { control: false },
soon: { control: false },
position: { control: false },
className: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies ButtonSize[],
props: (size: ButtonSize) => ({ size }),
},
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'focus',
'disabled+focus',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'active':
return { active: true };
case 'disabled+focus':
return { focus: true, disabled: true };
default:
return {};
}
},
},
{
name: 'accents',
values: ['default', 'blue', 'danger'] satisfies ButtonAccent[],
props: (accent: ButtonAccent) => ({ accent }),
},
{
name: 'variants',
values: [
'primary',
'secondary',
'tertiary',
] satisfies ButtonVariant[],
props: (variant: ButtonVariant) => ({ variant }),
},
],
},
},
decorators: [CatalogDecorator],
};
export const SoonCatalog: CatalogStory<Story, typeof Button> = {
args: { title: 'Filter', Icon: IconSearch, soon: true },
argTypes: {
size: { control: false },
variant: { control: false },
accent: { control: false },
disabled: { control: false },
focus: { control: false },
fullWidth: { control: false },
soon: { control: false },
position: { control: false },
className: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies ButtonSize[],
props: (size: ButtonSize) => ({ size }),
},
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'focus',
'disabled+focus',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'active':
return { active: true };
case 'disabled+focus':
return { focus: true, disabled: true };
default:
return {};
}
},
},
{
name: 'accents',
values: ['default', 'blue', 'danger'] satisfies ButtonAccent[],
props: (accent: ButtonAccent) => ({ accent }),
},
{
name: 'variants',
values: [
'primary',
'secondary',
'tertiary',
] satisfies ButtonVariant[],
props: (variant: ButtonVariant) => ({ variant }),
},
],
},
},
decorators: [CatalogDecorator],
};
export const PositionCatalog: CatalogStory<Story, typeof Button> = {
args: { title: 'Filter', Icon: IconSearch },
argTypes: {
size: { control: false },
variant: { control: false },
accent: { control: false },
disabled: { control: false },
focus: { control: false },
fullWidth: { control: false },
soon: { control: false },
position: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
catalog: {
dimensions: [
{
name: 'positions',
values: [
'standalone',
'left',
'middle',
'right',
] satisfies ButtonPosition[],
props: (position: ButtonPosition) => ({ position }),
},
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'focus',
'disabled+focus',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'active':
return { active: true };
case 'disabled+focus':
return { focus: true, disabled: true };
default:
return {};
}
},
},
{
name: 'sizes',
values: ['small', 'medium'] satisfies ButtonSize[],
props: (size: ButtonSize) => ({ size }),
},
{
name: 'variants',
values: [
'primary',
'secondary',
'tertiary',
] satisfies ButtonVariant[],
props: (variant: ButtonVariant) => ({ variant }),
},
],
},
},
decorators: [CatalogDecorator],
};
export const FullWidth: Story = {
args: { title: 'Filter', Icon: IconSearch, fullWidth: true },
argTypes: {
size: { control: false },
variant: { control: false },
accent: { control: false },
focus: { control: false },
disabled: { control: false },
fullWidth: { control: false },
soon: { control: false },
position: { control: false },
className: { control: false },
Icon: { control: false },
},
decorators: [ComponentDecorator],
};
@@ -0,0 +1,77 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/display/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import { Button, ButtonAccent, ButtonSize, ButtonVariant } from '../Button';
import { ButtonGroup } from '../ButtonGroup';
const meta: Meta<typeof ButtonGroup> = {
title: 'UI/Input/Button/ButtonGroup',
component: ButtonGroup,
};
export default meta;
type Story = StoryObj<typeof ButtonGroup>;
export const Default: Story = {
args: {
size: 'small',
variant: 'primary',
accent: 'danger',
children: [
<Button Icon={IconNotes} title="Note" />,
<Button Icon={IconCheckbox} title="Task" />,
<Button Icon={IconTimelineEvent} title="Activity" />,
],
},
argTypes: {
children: { control: false },
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof ButtonGroup> = {
args: {
children: [
<Button Icon={IconNotes} title="Note" />,
<Button Icon={IconCheckbox} title="Task" />,
<Button Icon={IconTimelineEvent} title="Activity" />,
],
},
argTypes: {
size: { control: false },
variant: { control: false },
accent: { control: false },
children: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies ButtonSize[],
props: (size: ButtonSize) => ({ size }),
},
{
name: 'accents',
values: ['default', 'blue', 'danger'] satisfies ButtonAccent[],
props: (accent: ButtonAccent) => ({ accent }),
},
{
name: 'variants',
values: [
'primary',
'secondary',
'tertiary',
] satisfies ButtonVariant[],
props: (variant: ButtonVariant) => ({ variant }),
},
],
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,84 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconSearch } from '@/ui/display/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import { FloatingButton, FloatingButtonSize } from '../FloatingButton';
const meta: Meta<typeof FloatingButton> = {
title: 'UI/Input/Button/FloatingButton',
component: FloatingButton,
};
export default meta;
type Story = StoryObj<typeof FloatingButton>;
export const Default: Story = {
args: {
title: 'Filter',
size: 'small',
disabled: false,
focus: false,
applyBlur: true,
applyShadow: true,
position: 'standalone',
Icon: IconSearch,
},
argTypes: {
Icon: { control: false },
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof FloatingButton> = {
args: { title: 'Filter', Icon: IconSearch },
argTypes: {
size: { control: false },
disabled: { control: false },
position: { control: false },
focus: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies FloatingButtonSize[],
props: (size: FloatingButtonSize) => ({ size }),
},
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'focus',
'disabled+focus',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'disabled+focus':
return { disabled: true, focus: true };
default:
return {};
}
},
},
],
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,59 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/display/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import { FloatingButton, FloatingButtonSize } from '../FloatingButton';
import { FloatingButtonGroup } from '../FloatingButtonGroup';
const meta: Meta<typeof FloatingButtonGroup> = {
title: 'UI/Input/Button/FloatingButtonGroup',
component: FloatingButtonGroup,
};
export default meta;
type Story = StoryObj<typeof FloatingButtonGroup>;
export const Default: Story = {
args: {
size: 'small',
children: [
<FloatingButton Icon={IconNotes} />,
<FloatingButton Icon={IconCheckbox} />,
<FloatingButton Icon={IconTimelineEvent} />,
],
},
argTypes: {
children: { control: false },
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof FloatingButtonGroup> = {
args: {
children: [
<FloatingButton Icon={IconNotes} />,
<FloatingButton Icon={IconCheckbox} />,
<FloatingButton Icon={IconTimelineEvent} />,
],
},
argTypes: {
size: { control: false },
children: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies FloatingButtonSize[],
props: (size: FloatingButtonSize) => ({ size }),
},
],
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,85 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconSearch } from '@/ui/display/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import {
FloatingIconButton,
FloatingIconButtonSize,
} from '../FloatingIconButton';
const meta: Meta<typeof FloatingIconButton> = {
title: 'UI/Input/Button/FloatingIconButton',
component: FloatingIconButton,
};
export default meta;
type Story = StoryObj<typeof FloatingIconButton>;
export const Default: Story = {
args: {
size: 'small',
disabled: false,
focus: false,
applyBlur: true,
applyShadow: true,
position: 'standalone',
Icon: IconSearch,
},
argTypes: {
Icon: { control: false },
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof FloatingIconButton> = {
args: { Icon: IconSearch },
argTypes: {
size: { control: false },
disabled: { control: false },
focus: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies FloatingIconButtonSize[],
props: (size: FloatingIconButtonSize) => ({ size }),
},
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'focus',
'disabled+focus',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'disabled+focus':
return { disabled: true, focus: true };
default:
return {};
}
},
},
],
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,53 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/display/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import { FloatingIconButtonSize } from '../FloatingIconButton';
import { FloatingIconButtonGroup } from '../FloatingIconButtonGroup';
const meta: Meta<typeof FloatingIconButtonGroup> = {
title: 'UI/Input/Button/FloatingIconButtonGroup',
component: FloatingIconButtonGroup,
args: {
iconButtons: [
{ Icon: IconNotes },
{ Icon: IconCheckbox },
{ Icon: IconTimelineEvent },
],
},
argTypes: {
iconButtons: { control: false },
},
};
export default meta;
type Story = StoryObj<typeof FloatingIconButtonGroup>;
export const Default: Story = {
args: {
size: 'small',
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof FloatingIconButtonGroup> = {
argTypes: {
size: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'], focus: ['.focus'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies FloatingIconButtonSize[],
props: (size: FloatingIconButtonSize) => ({ size }),
},
],
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,180 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconSearch } from '@/ui/display/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import {
IconButton,
IconButtonAccent,
IconButtonPosition,
IconButtonSize,
IconButtonVariant,
} from '../IconButton';
const meta: Meta<typeof IconButton> = {
title: 'UI/Input/Button/IconButton',
component: IconButton,
};
export default meta;
type Story = StoryObj<typeof IconButton>;
export const Default: Story = {
args: {
size: 'small',
variant: 'primary',
accent: 'danger',
disabled: false,
focus: false,
position: 'standalone',
Icon: IconSearch,
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof IconButton> = {
args: { Icon: IconSearch },
argTypes: {
size: { control: false },
variant: { control: false },
focus: { control: false },
accent: { control: false },
disabled: { control: false },
Icon: { control: false },
position: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'] },
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies IconButtonSize[],
props: (size: IconButtonSize) => ({ size }),
},
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'focus',
'disabled+focus',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'active':
return { active: true };
case 'disabled+focus':
return { focus: true, disabled: true };
default:
return {};
}
},
},
{
name: 'accents',
values: ['default', 'blue', 'danger'] satisfies IconButtonAccent[],
props: (accent: IconButtonAccent) => ({ accent }),
},
{
name: 'variants',
values: [
'primary',
'secondary',
'tertiary',
] satisfies IconButtonVariant[],
props: (variant: IconButtonVariant) => ({ variant }),
},
],
},
},
decorators: [CatalogDecorator],
};
export const PositionCatalog: CatalogStory<Story, typeof IconButton> = {
args: { Icon: IconSearch },
argTypes: {
size: { control: false },
variant: { control: false },
focus: { control: false },
accent: { control: false },
disabled: { control: false },
position: { control: false },
Icon: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'] },
catalog: {
dimensions: [
{
name: 'positions',
values: [
'standalone',
'left',
'middle',
'right',
] satisfies IconButtonPosition[],
props: (position: IconButtonPosition) => ({ position }),
},
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'focus',
'disabled+focus',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'active':
return { active: true };
case 'disabled+focus':
return { focus: true, disabled: true };
default:
return {};
}
},
},
{
name: 'sizes',
values: ['small', 'medium'] satisfies IconButtonSize[],
props: (size: IconButtonSize) => ({ size }),
},
{
name: 'variants',
values: [
'primary',
'secondary',
'tertiary',
] satisfies IconButtonVariant[],
props: (variant: IconButtonVariant) => ({ variant }),
},
],
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,74 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconCheckbox, IconNotes, IconTimelineEvent } from '@/ui/display/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import {
IconButtonAccent,
IconButtonSize,
IconButtonVariant,
} from '../IconButton';
import { IconButtonGroup } from '../IconButtonGroup';
const meta: Meta<typeof IconButtonGroup> = {
title: 'UI/Input/Button/IconButtonGroup',
component: IconButtonGroup,
args: {
iconButtons: [
{ Icon: IconNotes },
{ Icon: IconCheckbox },
{ Icon: IconTimelineEvent },
],
},
argTypes: {
iconButtons: { control: false },
},
};
export default meta;
type Story = StoryObj<typeof IconButtonGroup>;
export const Default: Story = {
args: {
size: 'small',
variant: 'primary',
accent: 'danger',
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof IconButtonGroup> = {
argTypes: {
size: { control: false },
variant: { control: false },
accent: { control: false },
},
parameters: {
catalog: {
dimensions: [
{
name: 'sizes',
values: ['small', 'medium'] satisfies IconButtonSize[],
props: (size: IconButtonSize) => ({ size }),
},
{
name: 'accents',
values: ['default', 'blue', 'danger'] satisfies IconButtonAccent[],
props: (accent: IconButtonAccent) => ({ accent }),
},
{
name: 'variants',
values: [
'primary',
'secondary',
'tertiary',
] satisfies IconButtonVariant[],
props: (variant: IconButtonVariant) => ({ variant }),
},
],
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,88 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconSearch } from '@/ui/display/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import { LightButton, LightButtonAccent } from '../LightButton';
const meta: Meta<typeof LightButton> = {
title: 'UI/Input/Button/LightButton',
component: LightButton,
};
export default meta;
type Story = StoryObj<typeof LightButton>;
export const Default: Story = {
args: {
title: 'Filter',
accent: 'secondary',
disabled: false,
active: false,
focus: false,
icon: <IconSearch />,
},
argTypes: {
icon: { control: false },
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof LightButton> = {
args: { title: 'Filter', icon: <IconSearch /> },
argTypes: {
accent: { control: false },
disabled: { control: false },
active: { control: false },
focus: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'] },
catalog: {
dimensions: [
{
name: 'accents',
values: ['secondary', 'tertiary'] satisfies LightButtonAccent[],
props: (accent: LightButtonAccent) => ({ accent }),
},
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'active',
'focus',
'disabled+focus',
'disabled+active',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'active':
return { active: true };
case 'disabled+focus':
return { disabled: true, focus: true };
case 'disabled+active':
return { disabled: true, active: true };
default:
return {};
}
},
},
],
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,97 @@
import { Meta, StoryObj } from '@storybook/react';
import { IconSearch } from '@/ui/display/icon';
import { CatalogDecorator } from '~/testing/decorators/CatalogDecorator';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { CatalogStory } from '~/testing/types';
import {
LightIconButton,
LightIconButtonAccent,
LightIconButtonSize,
} from '../LightIconButton';
const meta: Meta<typeof LightIconButton> = {
title: 'UI/Input/Button/LightIconButton',
component: LightIconButton,
};
export default meta;
type Story = StoryObj<typeof LightIconButton>;
export const Default: Story = {
args: {
title: 'Filter',
accent: 'secondary',
disabled: false,
active: false,
focus: false,
Icon: IconSearch,
},
argTypes: {
Icon: { control: false },
},
decorators: [ComponentDecorator],
};
export const Catalog: CatalogStory<Story, typeof LightIconButton> = {
args: { title: 'Filter', Icon: IconSearch },
argTypes: {
accent: { control: false },
disabled: { control: false },
active: { control: false },
focus: { control: false },
},
parameters: {
pseudo: { hover: ['.hover'], active: ['.pressed'] },
catalog: {
dimensions: [
{
name: 'states',
values: [
'default',
'hover',
'pressed',
'disabled',
'active',
'focus',
'disabled+focus',
'disabled+active',
],
props: (state: string) => {
switch (state) {
case 'default':
return {};
case 'hover':
case 'pressed':
return { className: state };
case 'focus':
return { focus: true };
case 'disabled':
return { disabled: true };
case 'active':
return { active: true };
case 'disabled+focus':
return { disabled: true, focus: true };
case 'disabled+active':
return { disabled: true, active: true };
default:
return {};
}
},
},
{
name: 'accents',
values: ['secondary', 'tertiary'] satisfies LightIconButtonAccent[],
props: (accent: LightIconButtonAccent) => ({ accent }),
},
{
name: 'sizes',
values: ['small', 'medium'] satisfies LightIconButtonSize[],
props: (size: LightIconButtonSize) => ({ size }),
},
],
},
},
decorators: [CatalogDecorator],
};
@@ -0,0 +1,59 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { IconBrandGoogle } from '@/ui/display/icon';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { MainButton } from '../MainButton';
const clickJestFn = fn();
const meta: Meta<typeof MainButton> = {
title: 'UI/Input/Button/MainButton',
component: MainButton,
decorators: [ComponentDecorator],
args: { title: 'A primary Button', onClick: clickJestFn },
};
export default meta;
type Story = StoryObj<typeof MainButton>;
export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(clickJestFn).toHaveBeenCalledTimes(0);
const button = canvas.getByRole('button');
await userEvent.click(button);
expect(clickJestFn).toHaveBeenCalledTimes(1);
},
};
export const WithIcon: Story = {
args: { Icon: IconBrandGoogle },
};
export const DisabledWithIcon: Story = {
args: { ...WithIcon.args, disabled: true },
};
export const FullWidth: Story = {
args: { fullWidth: true },
};
export const Secondary: Story = {
args: { title: 'A secondary Button', variant: 'secondary' },
};
export const SecondaryWithIcon: Story = {
args: { ...Secondary.args, ...WithIcon.args },
};
export const SecondaryDisabledWithIcon: Story = {
args: { ...SecondaryWithIcon.args, disabled: true },
};
export const SecondaryFullWidth: Story = {
args: { ...Secondary.args, ...FullWidth.args },
};
@@ -0,0 +1,32 @@
import { Meta, StoryObj } from '@storybook/react';
import { expect, fn, userEvent, within } from '@storybook/test';
import { IconArrowRight } from '@/ui/display/icon';
import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator';
import { RoundedIconButton } from '../RoundedIconButton';
const clickJestFn = fn();
const meta: Meta<typeof RoundedIconButton> = {
title: 'UI/Input/Button/RoundedIconButton',
component: RoundedIconButton,
};
export default meta;
type Story = StoryObj<typeof RoundedIconButton>;
export const Default: Story = {
decorators: [ComponentDecorator],
argTypes: { Icon: { control: false } },
args: { onClick: clickJestFn, Icon: IconArrowRight },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(clickJestFn).toHaveBeenCalledTimes(0);
const button = canvas.getByRole('button');
await userEvent.click(button);
expect(clickJestFn).toHaveBeenCalledTimes(1);
},
};
@@ -0,0 +1,239 @@
import React from 'react';
import styled from '@emotion/styled';
import {
AnimatePresence,
AnimationControls,
motion,
useAnimation,
} from 'framer-motion';
import { Checkmark } from '@/ui/display/checkmark/components/Checkmark';
import { ColorScheme } from '@/workspace-member/types/WorkspaceMember';
const StyledColorSchemeBackground = styled.div<
Pick<ColorSchemeCardProps, 'variant'>
>`
align-items: flex-end;
background: ${({ variant, theme }) => {
switch (variant) {
case 'Dark':
return theme.grayScale.gray75;
case 'Light':
default:
return theme.grayScale.gray15;
}
}};
border: ${({ variant, theme }) => {
switch (variant) {
case 'Dark':
return `1px solid ${theme.grayScale.gray70};`;
case 'Light':
default:
return `1px solid ${theme.grayScale.gray20};`;
}
}};
border-radius: ${({ theme }) => theme.border.radius.md};
box-sizing: border-box;
cursor: pointer;
display: flex;
height: 80px;
justify-content: flex-end;
overflow: hidden;
padding-left: ${({ theme }) => theme.spacing(6)};
padding-top: ${({ theme }) => theme.spacing(6)};
width: 120px;
`;
const StyledColorSchemeContent = styled(motion.div)<
Pick<ColorSchemeCardProps, 'variant'>
>`
background: ${({ theme, variant }) => {
switch (variant) {
case 'Dark':
return theme.grayScale.gray75;
case 'Light':
return theme.grayScale.gray0;
}
}};
border-left: ${({ variant, theme }) => {
switch (variant) {
case 'Dark':
return `1px solid ${theme.grayScale.gray60};`;
case 'Light':
default:
return `1px solid ${theme.grayScale.gray20};`;
}
}};
border-radius: ${({ theme }) => theme.border.radius.md} 0px 0px 0px;
border-top: ${({ variant, theme }) => {
switch (variant) {
case 'Dark':
return `1px solid ${theme.grayScale.gray60};`;
case 'Light':
default:
return `1px solid ${theme.grayScale.gray20};`;
}
}};
box-sizing: border-box;
color: ${({ variant, theme }) => {
switch (variant) {
case 'Dark':
return theme.grayScale.gray30;
case 'Light':
default:
return theme.grayScale.gray60;
}
}};
display: flex;
flex: 1;
font-size: 20px;
height: 56px;
padding-left: ${({ theme }) => theme.spacing(2)};
padding-top: ${({ theme }) => theme.spacing(2)};
`;
export type ColorSchemeSegmentProps = {
variant: ColorScheme;
controls: AnimationControls;
className?: string;
} & React.ComponentPropsWithoutRef<'div'>;
const ColorSchemeSegment = ({
variant,
controls,
style,
className,
onClick,
onMouseEnter,
onMouseLeave,
}: ColorSchemeSegmentProps) => (
<StyledColorSchemeBackground
className={className}
{...{ variant, style, onClick, onMouseEnter, onMouseLeave }}
>
<StyledColorSchemeContent animate={controls} variant={variant}>
Aa
</StyledColorSchemeContent>
</StyledColorSchemeBackground>
);
const StyledContainer = styled.div`
position: relative;
width: 120px;
`;
const StyledMixedColorSchemeSegment = styled.div`
border-radius: ${({ theme }) => theme.border.radius.md};
cursor: pointer;
display: flex;
height: 80px;
overflow: hidden;
position: relative;
width: 120px;
`;
const StyledCheckmarkContainer = styled(motion.div)`
bottom: 0px;
padding: ${({ theme }) => theme.spacing(2)};
position: absolute;
right: 0px;
`;
export type ColorSchemeCardProps = {
variant: ColorScheme;
selected?: boolean;
} & React.ComponentPropsWithoutRef<'div'>;
const checkmarkAnimationVariants = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
};
export const ColorSchemeCard = ({
variant,
selected,
onClick,
}: ColorSchemeCardProps) => {
const controls = useAnimation();
const handleMouseEnter = () => {
controls.start({
height: 61,
fontSize: '22px',
transition: { duration: 0.1 },
});
};
const handleMouseLeave = () => {
controls.start({
height: 56,
fontSize: '20px',
transition: { duration: 0.1 },
});
};
if (variant === 'System') {
return (
<StyledContainer>
<StyledMixedColorSchemeSegment
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={onClick}
>
<ColorSchemeSegment
style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }}
controls={controls}
variant="Light"
/>
<ColorSchemeSegment
style={{ borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }}
controls={controls}
variant="Dark"
/>
</StyledMixedColorSchemeSegment>
<AnimatePresence>
{selected && (
<StyledCheckmarkContainer
key="system"
variants={checkmarkAnimationVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.3 }}
>
<Checkmark />
</StyledCheckmarkContainer>
)}
</AnimatePresence>
</StyledContainer>
);
}
return (
<StyledContainer>
<ColorSchemeSegment
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
controls={controls}
variant={variant}
onClick={onClick}
/>
<AnimatePresence>
{selected && (
<StyledCheckmarkContainer
key={variant}
variants={checkmarkAnimationVariants}
initial="initial"
animate="animate"
exit="exit"
transition={{ duration: 0.3 }}
>
<Checkmark />
</StyledCheckmarkContainer>
)}
</AnimatePresence>
</StyledContainer>
);
};

Some files were not shown because too many files have changed in this diff Show More