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:
Thaïs
2024-04-15 12:05:06 +02:00
committed by GitHub
parent acc2092b95
commit b6d0b8a895
93 changed files with 225 additions and 189 deletions
@@ -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 } },
};
@@ -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 = {};
+8
View File
@@ -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';
@@ -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],
};
@@ -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>
);
+4
View File
@@ -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';
@@ -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=';
@@ -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';
@@ -0,0 +1 @@
export type Nullable<T> = T | null | undefined;