refactor: move Checkmark, Avatar, Chip and Tooltip to twenty-ui (#4946)
Split from https://github.com/twentyhq/twenty/pull/4518 Part of #4766
This commit is contained in:
123
packages/twenty-ui/src/display/avatar/components/Avatar.tsx
Normal file
123
packages/twenty-ui/src/display/avatar/components/Avatar.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { Nullable, stringToHslColor } from '@ui/utilities';
|
||||
|
||||
export type AvatarType = 'squared' | 'rounded';
|
||||
|
||||
export type AvatarSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs';
|
||||
|
||||
export type AvatarProps = {
|
||||
avatarUrl?: string | null;
|
||||
className?: string;
|
||||
size?: AvatarSize;
|
||||
placeholder: string | undefined;
|
||||
entityId?: string;
|
||||
type?: Nullable<AvatarType>;
|
||||
color?: string;
|
||||
backgroundColor?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
const propertiesBySize = {
|
||||
xl: {
|
||||
fontSize: '16px',
|
||||
width: '40px',
|
||||
},
|
||||
lg: {
|
||||
fontSize: '13px',
|
||||
width: '24px',
|
||||
},
|
||||
md: {
|
||||
fontSize: '12px',
|
||||
width: '16px',
|
||||
},
|
||||
sm: {
|
||||
fontSize: '10px',
|
||||
width: '14px',
|
||||
},
|
||||
xs: {
|
||||
fontSize: '8px',
|
||||
width: '12px',
|
||||
},
|
||||
};
|
||||
|
||||
export const StyledAvatar = styled.div<
|
||||
AvatarProps & { color: string; backgroundColor: string }
|
||||
>`
|
||||
align-items: center;
|
||||
background-color: ${({ backgroundColor }) => backgroundColor};
|
||||
${({ avatarUrl }) =>
|
||||
isNonEmptyString(avatarUrl) ? `background-image: url(${avatarUrl});` : ''}
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
border-radius: ${(props) => (props.type === 'rounded' ? '50%' : '2px')};
|
||||
color: ${({ color }) => color};
|
||||
cursor: ${({ onClick }) => (onClick ? 'pointer' : 'default')};
|
||||
display: flex;
|
||||
|
||||
flex-shrink: 0;
|
||||
font-size: ${({ size = 'md' }) => propertiesBySize[size].fontSize};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
|
||||
height: ${({ size = 'md' }) => propertiesBySize[size].width};
|
||||
justify-content: center;
|
||||
width: ${({ size = 'md' }) => propertiesBySize[size].width};
|
||||
|
||||
&:hover {
|
||||
box-shadow: ${({ theme, onClick }) =>
|
||||
onClick ? '0 0 0 4px ' + theme.background.transparent.light : 'unset'};
|
||||
}
|
||||
`;
|
||||
|
||||
export const Avatar = ({
|
||||
avatarUrl,
|
||||
className,
|
||||
size = 'md',
|
||||
placeholder,
|
||||
entityId = placeholder,
|
||||
onClick,
|
||||
type = 'squared',
|
||||
color,
|
||||
backgroundColor,
|
||||
}: AvatarProps) => {
|
||||
const noAvatarUrl = !isNonEmptyString(avatarUrl);
|
||||
const [isInvalidAvatarUrl, setIsInvalidAvatarUrl] = useState(false);
|
||||
useEffect(() => {
|
||||
if (isNonEmptyString(avatarUrl)) {
|
||||
new Promise((resolve) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(false);
|
||||
img.onerror = () => resolve(true);
|
||||
img.src = avatarUrl;
|
||||
}).then((res) => {
|
||||
setIsInvalidAvatarUrl(res as boolean);
|
||||
});
|
||||
}
|
||||
}, [avatarUrl]);
|
||||
|
||||
const fixedColor = color ?? stringToHslColor(entityId ?? '', 75, 25);
|
||||
const fixedBackgroundColor =
|
||||
backgroundColor ??
|
||||
(!isNonEmptyString(avatarUrl)
|
||||
? stringToHslColor(entityId ?? '', 75, 85)
|
||||
: 'none');
|
||||
|
||||
return (
|
||||
<StyledAvatar
|
||||
className={className}
|
||||
avatarUrl={avatarUrl}
|
||||
placeholder={placeholder}
|
||||
size={size}
|
||||
type={type}
|
||||
entityId={entityId}
|
||||
onClick={onClick}
|
||||
color={fixedColor}
|
||||
backgroundColor={fixedBackgroundColor}
|
||||
>
|
||||
{(noAvatarUrl || isInvalidAvatarUrl) &&
|
||||
placeholder?.[0]?.toLocaleUpperCase()}
|
||||
</StyledAvatar>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
import { ReactNode } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
export type AvatarGroupProps = {
|
||||
avatars: ReactNode[];
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
const StyledItemContainer = styled.div`
|
||||
margin-right: -3px;
|
||||
`;
|
||||
|
||||
const MAX_AVATARS_NB = 4;
|
||||
|
||||
export const AvatarGroup = ({ avatars }: AvatarGroupProps) => {
|
||||
if (!avatars.length) return null;
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
{avatars.slice(0, MAX_AVATARS_NB).map((avatar, index) => (
|
||||
<StyledItemContainer key={index}>{avatar}</StyledItemContainer>
|
||||
))}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,37 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import { AVATAR_URL_MOCK, ComponentDecorator } from '@ui/testing';
|
||||
|
||||
import { Avatar } from '../Avatar';
|
||||
|
||||
const meta: Meta<typeof Avatar> = {
|
||||
title: 'Modules/Users/Avatar',
|
||||
component: Avatar,
|
||||
decorators: [ComponentDecorator],
|
||||
args: {
|
||||
avatarUrl: AVATAR_URL_MOCK,
|
||||
size: 'md',
|
||||
placeholder: 'L',
|
||||
type: 'rounded',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Avatar>;
|
||||
|
||||
export const Rounded: Story = {};
|
||||
|
||||
export const Squared: Story = {
|
||||
args: { type: 'squared' },
|
||||
};
|
||||
|
||||
export const NoAvatarPictureRounded: Story = {
|
||||
args: { avatarUrl: '' },
|
||||
};
|
||||
|
||||
export const NoAvatarPictureSquared: Story = {
|
||||
args: {
|
||||
...NoAvatarPictureRounded.args,
|
||||
...Squared.args,
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,70 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import {
|
||||
Avatar,
|
||||
AvatarProps,
|
||||
AvatarSize,
|
||||
AvatarType,
|
||||
} from '@ui/display/avatar/components/Avatar';
|
||||
import {
|
||||
AVATAR_URL_MOCK,
|
||||
CatalogDecorator,
|
||||
ComponentDecorator,
|
||||
} from '@ui/testing';
|
||||
|
||||
import { AvatarGroup, AvatarGroupProps } from '../AvatarGroup';
|
||||
|
||||
const makeAvatar = (userName: string, props: Partial<AvatarProps> = {}) => (
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
<Avatar placeholder={userName} entityId={userName} {...props} />
|
||||
);
|
||||
|
||||
const getAvatars = (commonProps: Partial<AvatarProps> = {}) => [
|
||||
makeAvatar('Matthew', { avatarUrl: AVATAR_URL_MOCK, ...commonProps }),
|
||||
makeAvatar('Sophie', commonProps),
|
||||
makeAvatar('Jane', commonProps),
|
||||
makeAvatar('Lily', commonProps),
|
||||
makeAvatar('John', commonProps),
|
||||
];
|
||||
|
||||
const meta: Meta<
|
||||
AvatarGroupProps & AvatarProps & { numberOfAvatars?: number }
|
||||
> = {
|
||||
title: 'Modules/Users/AvatarGroup',
|
||||
component: AvatarGroup,
|
||||
render: ({ numberOfAvatars = 5, ...args }) => (
|
||||
<AvatarGroup avatars={getAvatars(args).slice(0, numberOfAvatars)} />
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AvatarGroup>;
|
||||
|
||||
export const Default: Story = {
|
||||
decorators: [ComponentDecorator],
|
||||
};
|
||||
|
||||
export const Catalog: Story = {
|
||||
parameters: {
|
||||
catalog: {
|
||||
dimensions: [
|
||||
{
|
||||
name: 'number of avatars',
|
||||
values: [1, 2, 3, 4, 5],
|
||||
props: (numberOfAvatars: number) => ({ numberOfAvatars }),
|
||||
},
|
||||
{
|
||||
name: 'types',
|
||||
values: ['rounded', 'squared'] as AvatarType[],
|
||||
props: (type: AvatarType) => ({ type }),
|
||||
},
|
||||
{
|
||||
name: 'sizes',
|
||||
values: ['xs', 'sm', 'md', 'lg', 'xl'] as AvatarSize[],
|
||||
props: (size: AvatarSize) => ({ size }),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [CatalogDecorator],
|
||||
};
|
||||
@ -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/components/TablerIcons';
|
||||
|
||||
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 '@ui/testing/decorators/ComponentDecorator';
|
||||
|
||||
import { Checkmark } from '../Checkmark';
|
||||
|
||||
const meta: Meta<typeof Checkmark> = {
|
||||
title: 'UI/Display/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 } },
|
||||
};
|
||||
178
packages/twenty-ui/src/display/chip/components/Chip.tsx
Normal file
178
packages/twenty-ui/src/display/chip/components/Chip.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import { MouseEvent, ReactNode } from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { OverflowingTextWithTooltip } from '@ui/display/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?: number;
|
||||
variant?: ChipVariant;
|
||||
accent?: ChipAccent;
|
||||
leftComponent?: ReactNode;
|
||||
rightComponent?: ReactNode;
|
||||
className?: string;
|
||||
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
|
||||
};
|
||||
|
||||
const StyledContainer = styled.div<
|
||||
Pick<
|
||||
ChipProps,
|
||||
'accent' | 'clickable' | 'disabled' | 'maxWidth' | 'size' | 'variant'
|
||||
>
|
||||
>`
|
||||
--chip-horizontal-padding: ${({ theme }) => theme.spacing(1)};
|
||||
--chip-vertical-padding: ${({ theme }) => theme.spacing(1)};
|
||||
|
||||
align-items: center;
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
color: ${({ theme, disabled }) =>
|
||||
disabled ? theme.font.color.light : theme.font.color.secondary};
|
||||
cursor: ${({ clickable, disabled }) =>
|
||||
clickable ? 'pointer' : disabled ? 'not-allowed' : 'inherit'};
|
||||
display: inline-flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
height: ${({ theme }) => theme.spacing(3)};
|
||||
max-width: ${({ maxWidth }) =>
|
||||
maxWidth
|
||||
? `calc(${maxWidth}px - 2 * var(--chip-horizontal-padding))`
|
||||
: '200px'};
|
||||
overflow: hidden;
|
||||
padding: var(--chip-vertical-padding) var(--chip-horizontal-padding);
|
||||
user-select: none;
|
||||
|
||||
// Accent style overrides
|
||||
${({ accent, disabled, theme }) => {
|
||||
if (accent === ChipAccent.TextPrimary) {
|
||||
return (
|
||||
!disabled &&
|
||||
css`
|
||||
color: ${theme.font.color.primary};
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
if (accent === ChipAccent.TextSecondary) {
|
||||
return css`
|
||||
font-weight: ${theme.font.weight.medium};
|
||||
`;
|
||||
}
|
||||
}}
|
||||
|
||||
// Size style overrides
|
||||
${({ theme, size }) =>
|
||||
size === ChipSize.Large &&
|
||||
css`
|
||||
height: ${theme.spacing(4)};
|
||||
`}
|
||||
|
||||
// Variant style overrides
|
||||
${({ disabled, theme, variant }) => {
|
||||
if (variant === ChipVariant.Regular) {
|
||||
return (
|
||||
!disabled &&
|
||||
css`
|
||||
:hover {
|
||||
background-color: ${theme.background.transparent.light};
|
||||
}
|
||||
|
||||
:active {
|
||||
background-color: ${theme.background.transparent.medium};
|
||||
}
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === ChipVariant.Highlighted) {
|
||||
return css`
|
||||
background-color: ${theme.background.transparent.light};
|
||||
|
||||
${!disabled &&
|
||||
css`
|
||||
:hover {
|
||||
background-color: ${theme.background.transparent.medium};
|
||||
}
|
||||
|
||||
:active {
|
||||
background-color: ${theme.background.transparent.strong};
|
||||
}
|
||||
`}
|
||||
`;
|
||||
}
|
||||
|
||||
if (variant === ChipVariant.Rounded) {
|
||||
return css`
|
||||
--chip-horizontal-padding: ${theme.spacing(2)};
|
||||
--chip-vertical-padding: 3px;
|
||||
|
||||
background-color: ${theme.background.transparent.lighter};
|
||||
border: 1px solid ${theme.border.color.medium};
|
||||
border-radius: 50px;
|
||||
`;
|
||||
}
|
||||
|
||||
if (variant === ChipVariant.Transparent) {
|
||||
return css`
|
||||
cursor: inherit;
|
||||
`;
|
||||
}
|
||||
}}
|
||||
`;
|
||||
|
||||
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,81 @@
|
||||
import * as React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
|
||||
import { Avatar, AvatarType } from '@ui/display/avatar/components/Avatar';
|
||||
import { IconComponent } from '@ui/display/icon/types/IconComponent';
|
||||
import { Nullable } from '@ui/utilities/types/Nullable';
|
||||
|
||||
import { Chip, ChipVariant } from './Chip';
|
||||
|
||||
export type EntityChipProps = {
|
||||
linkToEntity?: string;
|
||||
entityId: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
avatarType?: Nullable<AvatarType>;
|
||||
variant?: EntityChipVariant;
|
||||
LeftIcon?: IconComponent;
|
||||
className?: string;
|
||||
maxWidth?: number;
|
||||
};
|
||||
|
||||
export enum EntityChipVariant {
|
||||
Regular = 'regular',
|
||||
Transparent = 'transparent',
|
||||
}
|
||||
|
||||
export const EntityChip = ({
|
||||
linkToEntity,
|
||||
entityId,
|
||||
name,
|
||||
avatarUrl,
|
||||
avatarType = 'rounded',
|
||||
variant = EntityChipVariant.Regular,
|
||||
LeftIcon,
|
||||
className,
|
||||
maxWidth,
|
||||
}: EntityChipProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const handleLinkClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isNonEmptyString(linkToEntity)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
navigate(linkToEntity);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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}
|
||||
entityId={entityId}
|
||||
placeholder={name}
|
||||
size="sm"
|
||||
type={avatarType}
|
||||
/>
|
||||
)
|
||||
}
|
||||
clickable={!!linkToEntity}
|
||||
onClick={handleLinkClick}
|
||||
className={className}
|
||||
maxWidth={maxWidth}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,71 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import {
|
||||
CatalogDecorator,
|
||||
CatalogStory,
|
||||
ComponentDecorator,
|
||||
} from '@ui/testing';
|
||||
|
||||
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: 200,
|
||||
},
|
||||
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 { ComponentDecorator, RouterDecorator } from '@ui/testing';
|
||||
|
||||
import { EntityChip } from '../EntityChip';
|
||||
|
||||
const meta: Meta<typeof EntityChip> = {
|
||||
title: 'UI/Display/Chip/EntityChip',
|
||||
component: EntityChip,
|
||||
decorators: [RouterDecorator, ComponentDecorator],
|
||||
args: {
|
||||
name: 'Entity name',
|
||||
linkToEntity: '/entity-link',
|
||||
avatarType: 'squared',
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EntityChip>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@ -1,3 +1,9 @@
|
||||
export * from './avatar/components/Avatar';
|
||||
export * from './avatar/components/AvatarGroup';
|
||||
export * from './checkmark/components/AnimatedCheckmark';
|
||||
export * from './checkmark/components/Checkmark';
|
||||
export * from './chip/components/Chip';
|
||||
export * from './chip/components/EntityChip';
|
||||
export * from './icon/components/IconAddressBook';
|
||||
export * from './icon/components/IconGmail';
|
||||
export * from './icon/components/IconGoogle';
|
||||
@ -9,3 +15,5 @@ export * from './icon/hooks/useIcons';
|
||||
export * from './icon/providers/IconsProvider';
|
||||
export * from './icon/states/iconsState';
|
||||
export * from './icon/types/IconComponent';
|
||||
export * from './tooltip/AppTooltip';
|
||||
export * from './tooltip/OverflowingTextWithTooltip';
|
||||
|
||||
73
packages/twenty-ui/src/display/tooltip/AppTooltip.tsx
Normal file
73
packages/twenty-ui/src/display/tooltip/AppTooltip.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import { PlacesType, PositionStrategy, Tooltip } from 'react-tooltip';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { RGBA } from '@ui/theme/constants/Rgba';
|
||||
|
||||
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;
|
||||
children?: React.ReactNode;
|
||||
delayHide?: number;
|
||||
offset?: number;
|
||||
noArrow?: boolean;
|
||||
isOpen?: boolean;
|
||||
place?: PlacesType;
|
||||
positionStrategy?: PositionStrategy;
|
||||
};
|
||||
|
||||
export const AppTooltip = ({
|
||||
anchorSelect,
|
||||
className,
|
||||
content,
|
||||
delayHide,
|
||||
isOpen,
|
||||
noArrow,
|
||||
offset,
|
||||
place,
|
||||
positionStrategy,
|
||||
children,
|
||||
}: AppTooltipProps) => (
|
||||
<StyledAppTooltip
|
||||
{...{
|
||||
anchorSelect,
|
||||
className,
|
||||
content,
|
||||
delayHide,
|
||||
isOpen,
|
||||
noArrow,
|
||||
offset,
|
||||
place,
|
||||
positionStrategy,
|
||||
children,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
@ -0,0 +1,84 @@
|
||||
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,
|
||||
mutliline,
|
||||
}: {
|
||||
text: string | null | undefined;
|
||||
className?: string;
|
||||
mutliline?: boolean;
|
||||
}) => {
|
||||
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={mutliline ? undefined : text ?? ''}
|
||||
delayHide={0}
|
||||
offset={5}
|
||||
noArrow
|
||||
place="bottom"
|
||||
positionStrategy="absolute"
|
||||
>
|
||||
{mutliline ? <pre>{text}</pre> : ''}
|
||||
</AppTooltip>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { userEvent, within } from '@storybook/test';
|
||||
|
||||
import { ComponentDecorator } from '@ui/testing';
|
||||
|
||||
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,84 @@
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
|
||||
import {
|
||||
CatalogDecorator,
|
||||
CatalogStory,
|
||||
ComponentDecorator,
|
||||
} from '@ui/testing';
|
||||
|
||||
import { AppTooltip as Tooltip, TooltipPosition } from '../AppTooltip';
|
||||
|
||||
const meta: Meta<typeof Tooltip> = {
|
||||
title: 'UI/Display/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],
|
||||
};
|
||||
160
packages/twenty-ui/src/testing/decorators/CatalogDecorator.tsx
Normal file
160
packages/twenty-ui/src/testing/decorators/CatalogDecorator.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import { ComponentProps, JSX } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { isNumber, isString } from '@sniptt/guards';
|
||||
import { Decorator } from '@storybook/react';
|
||||
|
||||
const StyledColumnTitle = styled.h1`
|
||||
font-size: ${({ theme }) => theme.font.size.lg};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
margin: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledRowsTitle = styled.h2`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
margin: ${({ theme }) => theme.spacing(2)};
|
||||
width: 100px;
|
||||
`;
|
||||
|
||||
const StyledRowTitle = styled.h3`
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
margin: ${({ theme }) => theme.spacing(2)};
|
||||
width: 100px;
|
||||
`;
|
||||
|
||||
const StyledElementTitle = styled.span`
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
font-size: ${({ theme }) => theme.font.size.xs};
|
||||
font-weight: ${({ theme }) => theme.font.weight.semiBold};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(1)};
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const StyledColumnContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledRowsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledRowContainer = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledElementContainer = styled.div<{ width: number }>`
|
||||
display: flex;
|
||||
${({ width }) => width && `min-width: ${width}px;`}
|
||||
`;
|
||||
|
||||
const StyledCellContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const emptyDimension = {
|
||||
name: '',
|
||||
values: [undefined],
|
||||
props: () => ({}),
|
||||
} as CatalogDimension;
|
||||
|
||||
const isStringOrNumber = (term: unknown): term is string | number =>
|
||||
isString(term) || isNumber(term);
|
||||
|
||||
export type CatalogDimension<
|
||||
ComponentType extends React.ElementType = () => JSX.Element,
|
||||
> = {
|
||||
name: string;
|
||||
values: any[];
|
||||
props: (value: any) => Partial<ComponentProps<ComponentType>>;
|
||||
labels?: (value: any) => string;
|
||||
};
|
||||
|
||||
export type CatalogOptions = {
|
||||
elementContainer?: {
|
||||
width?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const CatalogDecorator: Decorator = (Story, context) => {
|
||||
const {
|
||||
catalog: { dimensions, options },
|
||||
} = context.parameters;
|
||||
|
||||
const [
|
||||
dimension1,
|
||||
dimension2 = emptyDimension,
|
||||
dimension3 = emptyDimension,
|
||||
dimension4 = emptyDimension,
|
||||
] = dimensions as CatalogDimension[];
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
{dimension4.values.map((value4: any) => (
|
||||
<StyledColumnContainer key={value4}>
|
||||
<StyledColumnTitle>
|
||||
{dimension4.labels?.(value4) ??
|
||||
(isStringOrNumber(value4) ? value4 : '')}
|
||||
</StyledColumnTitle>
|
||||
{dimension3.values.map((value3: any) => (
|
||||
<StyledRowsContainer key={value3}>
|
||||
<StyledRowsTitle>
|
||||
{dimension3.labels?.(value3) ??
|
||||
(isStringOrNumber(value3) ? value3 : '')}
|
||||
</StyledRowsTitle>
|
||||
{dimension2.values.map((value2: any) => (
|
||||
<StyledRowContainer key={value2}>
|
||||
<StyledRowTitle>
|
||||
{dimension2.labels?.(value2) ??
|
||||
(isStringOrNumber(value2) ? value2 : '')}
|
||||
</StyledRowTitle>
|
||||
{dimension1.values.map((value1: any) => {
|
||||
return (
|
||||
<StyledCellContainer key={value1} id={value1}>
|
||||
<StyledElementTitle>
|
||||
{dimension1.labels?.(value1) ??
|
||||
(isStringOrNumber(value1) ? value1 : '')}
|
||||
</StyledElementTitle>
|
||||
<StyledElementContainer
|
||||
width={options?.elementContainer?.width}
|
||||
>
|
||||
<Story
|
||||
args={{
|
||||
...context.args,
|
||||
...dimension1.props(value1),
|
||||
...dimension2.props(value2),
|
||||
...dimension3.props(value3),
|
||||
...dimension4.props(value4),
|
||||
}}
|
||||
/>
|
||||
</StyledElementContainer>
|
||||
</StyledCellContainer>
|
||||
);
|
||||
})}
|
||||
</StyledRowContainer>
|
||||
))}
|
||||
</StyledRowsContainer>
|
||||
))}
|
||||
</StyledColumnContainer>
|
||||
))}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,8 @@
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { Decorator } from '@storybook/react';
|
||||
|
||||
export const RouterDecorator: Decorator = (Story) => (
|
||||
<MemoryRouter>
|
||||
<Story />
|
||||
</MemoryRouter>
|
||||
);
|
||||
@ -1,2 +1,6 @@
|
||||
export * from './ComponentStorybookLayout';
|
||||
export * from './decorators/CatalogDecorator';
|
||||
export * from './decorators/ComponentDecorator';
|
||||
export * from './decorators/RouterDecorator';
|
||||
export * from './mocks/avatarUrlMock';
|
||||
export * from './types/CatalogStory';
|
||||
|
||||
2
packages/twenty-ui/src/testing/mocks/avatarUrlMock.ts
Normal file
2
packages/twenty-ui/src/testing/mocks/avatarUrlMock.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const AVATAR_URL_MOCK =
|
||||
'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAYABgAAD/4QCMRXhpZgAATU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAABgAAAAAQAAAGAAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAABSgAwAEAAAAAQAAABQAAAAA/8AAEQgAFAAUAwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMACwgICggHCwoJCg0MCw0RHBIRDw8RIhkaFBwpJCsqKCQnJy0yQDctMD0wJyc4TDk9Q0VISUgrNk9VTkZUQEdIRf/bAEMBDA0NEQ8RIRISIUUuJy5FRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRf/dAAQAAv/aAAwDAQACEQMRAD8Ava1q728otYY98joSCTgZrnbXWdTtrhrfVZXWLafmcAEkdgR/hVltQku9Q8+OIEBcGOT+ID0PY1ka1KH2u8ToqnPLbmIqG7u6LtbQ7RXBRec4Uck9eKXcPWsKDWVnhWSL5kYcFelSf2m3901POh8jP//QoyIAnTuKpXsY82NsksUyWPU5q/L9z8RVK++/F/uCsVsaEURwgA4HtT9x9TUcf3KfUGh//9k=';
|
||||
24
packages/twenty-ui/src/testing/types/CatalogStory.ts
Normal file
24
packages/twenty-ui/src/testing/types/CatalogStory.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import { ElementType } from 'react';
|
||||
import { StoryObj } from '@storybook/react';
|
||||
|
||||
import {
|
||||
CatalogDimension,
|
||||
CatalogOptions,
|
||||
} from '../decorators/CatalogDecorator';
|
||||
|
||||
export type CatalogStory<
|
||||
StoryType extends StoryObj<ComponentType>,
|
||||
ComponentType extends ElementType,
|
||||
> = {
|
||||
args?: StoryType['args'];
|
||||
argTypes?: StoryType['argTypes'];
|
||||
play?: StoryType['play'];
|
||||
render?: StoryType['render'];
|
||||
parameters: StoryType['parameters'] & {
|
||||
catalog: {
|
||||
dimensions: CatalogDimension<ComponentType>[];
|
||||
options?: CatalogOptions;
|
||||
};
|
||||
};
|
||||
decorators: StoryType['decorators'];
|
||||
};
|
||||
@ -0,0 +1,7 @@
|
||||
import { stringToHslColor } from '../stringToHslColor';
|
||||
|
||||
describe('stringToHslColor', () => {
|
||||
it('should return a color based on a string', () => {
|
||||
expect(stringToHslColor('red', 70, 90)).toBe('hsl(105, 70%, 90%)');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,13 @@
|
||||
export const stringToHslColor = (
|
||||
str: string,
|
||||
saturation: number,
|
||||
lightness: number,
|
||||
) => {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
const h = hash % 360;
|
||||
return 'hsl(' + h + ', ' + saturation + '%, ' + lightness + '%)';
|
||||
};
|
||||
@ -1 +1,3 @@
|
||||
export * from './color/utils/stringToHslColor';
|
||||
export * from './state/utils/createState';
|
||||
export * from './types/Nullable';
|
||||
|
||||
1
packages/twenty-ui/src/utilities/types/Nullable.ts
Normal file
1
packages/twenty-ui/src/utilities/types/Nullable.ts
Normal file
@ -0,0 +1 @@
|
||||
export type Nullable<T> = T | null | undefined;
|
||||
Reference in New Issue
Block a user