Feat/performance-refactor-styled-component (#5516)

In this PR I'm optimizing a whole RecordTableCell in real conditions
with a complex RelationFieldDisplay component :
- Broke down getObjectRecordIdentifier into multiple utils
- Precompute memoized function for getting chip data per field with
useRecordChipDataGenerator()
- Refactored RelationFieldDisplay
- Use CSS modules where performance is needed instead of styled
components
- Create a CSS theme with global CSS variables to be used by CSS modules
This commit is contained in:
Lucas Bordeau
2024-05-24 18:53:37 +02:00
committed by GitHub
parent 3680647c9a
commit a0178478d4
39 changed files with 1045 additions and 462 deletions

View File

@ -0,0 +1,23 @@
.avatar {
align-items: center;
border-radius: 2px;
display: flex;
flex-shrink: 0;
justify-content: center;
overflow: hidden;
user-select: none;
}
.rounded {
border-radius: 50%;
}
.avatar-on-click:hover {
box-shadow: 0 0 0 4px var(--twentycrm-background-transparent-light);
}
.avatar-image {
object-fit: cover;
width: 100%;
height: 100%;
}

View File

@ -1,9 +1,11 @@
import { useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { isNonEmptyString, isUndefined } from '@sniptt/guards';
import clsx from 'clsx';
import { Nullable, stringToHslColor } from '@ui/utilities';
import styles from './Avatar.module.css';
export type AvatarType = 'squared' | 'rounded';
export type AvatarSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs';
@ -43,37 +45,8 @@ const propertiesBySize = {
},
};
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,
@ -82,42 +55,50 @@ export const Avatar = ({
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 noAvatarUrl = !isNonEmptyString(avatarUrl);
const placeholderChar = placeholder?.[0]?.toLocaleUpperCase();
const showPlaceholder = noAvatarUrl || isInvalidAvatarUrl;
const handleImageError = () => {
setIsInvalidAvatarUrl(true);
};
const fixedColor = color ?? stringToHslColor(entityId ?? '', 75, 25);
const fixedBackgroundColor =
backgroundColor ??
(!isNonEmptyString(avatarUrl)
? stringToHslColor(entityId ?? '', 75, 85)
: 'none');
backgroundColor ?? stringToHslColor(entityId ?? '', 75, 85);
const showBackgroundColor = showPlaceholder;
return (
<StyledAvatar
className={className}
avatarUrl={avatarUrl}
placeholder={placeholder}
size={size}
type={type}
entityId={entityId}
<div
className={clsx({
[styles.avatar]: true,
[styles.rounded]: type === 'rounded',
[styles.avatarOnClick]: !isUndefined(onClick),
})}
onClick={onClick}
color={fixedColor}
backgroundColor={fixedBackgroundColor}
style={{
color: fixedColor,
backgroundColor: showBackgroundColor ? fixedBackgroundColor : 'none',
width: propertiesBySize[size].width,
height: propertiesBySize[size].width,
fontSize: propertiesBySize[size].fontSize,
}}
>
{(noAvatarUrl || isInvalidAvatarUrl) &&
placeholder?.[0]?.toLocaleUpperCase()}
</StyledAvatar>
{showPlaceholder ? (
placeholderChar
) : (
<img
src={avatarUrl}
className={styles.avatarImage}
onError={handleImageError}
alt=""
/>
)}
</div>
);
};

View File

@ -0,0 +1,84 @@
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chip {
--chip-horizontal-padding: calc(var(--twentycrm-spacing-multiplicator) * 1px);
--chip-vertical-padding: calc(var(--twentycrm-spacing-multiplicator) * 1px);
align-items: center;
border-radius: var(--twentycrm-border-radius-sm);
color: var(--twentycrm-font-color-secondary);
display: inline-flex;
justify-content: center;
gap: calc(var(--twentycrm-spacing-multiplicator) * 1px);
height: calc(var(--twentycrm-spacing-multiplicator) * 3px);
max-width: calc(100% - var(--chip-horizontal-padding) * 2px);
overflow: hidden;
padding: var(--chip-vertical-padding) var(--chip-horizontal-padding);
user-select: none;
}
.disabled {
cursor: not-allowed;
color: var(--twentycrm-font-color-light);
}
.clickable {
cursor: pointer;
}
.accent-text-primary {
color: var(--twentycrm-font-color-primary);
}
.accent-text-secondary {
font-weight: var(--twentycrm-font-weight-medium);
}
.size-large {
height: calc(var(--twentycrm-spacing-multiplicator) * 4px);
}
.variant-regular:hover {
background-color: var(--twentycrm-background-transparent-light);
}
.variant-regular:active {
background-color: var(--twentycrm-background-transparent-medium);
}
.variant-highlighted {
background-color: var(--twentycrm-background-transparent-light);
}
.variant-highlighted:hover {
background-color: var(--twentycrm-background-transparent-medium);
}
.variant-highlighted:active {
background-color: var(--twentycrm-background-transparent-strong);
}
.variant-rounded {
--chip-horizontal-padding: calc(var(--twentycrm-spacing-multiplicator) * 2px);
--chip-vertical-padding: 3px;
background-color: var(--twentycrm-background-transparent-light);
border: 1px solid var(--twentycrm-border-color-medium);
border-radius: 50px;
}
.variant-transparent {
cursor: inherit;
}

View File

@ -1,11 +1,10 @@
import { MouseEvent, ReactNode } from 'react';
import { Link } from 'react-router-dom';
import isPropValid from '@emotion/is-prop-valid';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { clsx } from 'clsx';
import { OverflowingTextWithTooltip } from '@ui/display/tooltip/OverflowingTextWithTooltip';
import styles from './Chip.module.css';
export enum ChipSize {
Large = 'large',
Small = 'small',
@ -38,121 +37,6 @@ type ChipProps = {
to?: string;
};
const StyledContainer = styled('div', {
shouldForwardProp: (prop) =>
!['clickable', 'maxWidth'].includes(prop) && isPropValid(prop),
})<
Pick<
ChipProps,
'accent' | 'clickable' | 'disabled' | 'maxWidth' | 'size' | 'variant' | 'to'
>
>`
--chip-horizontal-padding: ${({ theme }) => theme.spacing(1)};
--chip-vertical-padding: ${({ theme }) => theme.spacing(1)};
text-decoration: none;
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;
justify-content: center;
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};
`;
}
}}
// 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;
`;
const StyledOverflowingTextWithTooltip = styled(OverflowingTextWithTooltip)<{
size?: ChipSize;
}>`
height: ${({ theme, size }) =>
size === ChipSize.Large ? theme.spacing(4) : 'auto'};
`;
export const Chip = ({
size = ChipSize.Small,
label,
@ -162,30 +46,33 @@ export const Chip = ({
leftComponent,
rightComponent,
accent = ChipAccent.TextPrimary,
maxWidth,
className,
onClick,
to,
}: ChipProps) => {
return (
<StyledContainer
<div
data-testid="chip"
clickable={clickable}
variant={variant}
accent={accent}
size={size}
disabled={disabled}
className={className}
maxWidth={maxWidth}
className={clsx({
[styles.chip]: true,
[styles.clickable]: clickable,
[styles.disabled]: disabled,
[styles.accentTextPrimary]: accent === ChipAccent.TextPrimary,
[styles.accentTextSecondary]: accent === ChipAccent.TextSecondary,
[styles.sizeLarge]: size === ChipSize.Large,
[styles.variantRegular]: variant === ChipVariant.Regular,
[styles.variantHighlighted]: variant === ChipVariant.Highlighted,
[styles.variantRounded]: variant === ChipVariant.Rounded,
[styles.variantTransparent]: variant === ChipVariant.Transparent,
})}
onClick={onClick}
as={to ? Link : 'div'}
to={to ? to : undefined}
>
{leftComponent}
<StyledLabel>
<StyledOverflowingTextWithTooltip size={size} text={label} />
</StyledLabel>
<div className={styles.label}>
<OverflowingTextWithTooltip
size={size === ChipSize.Large ? 'large' : 'small'}
text={label}
/>
</div>
{rightComponent}
</StyledContainer>
</div>
);
};

View File

@ -3,11 +3,10 @@ import { useTheme } from '@emotion/react';
import { isNonEmptyString } from '@sniptt/guards';
import { Avatar, AvatarType } from '@ui/display/avatar/components/Avatar';
import { Chip, ChipVariant } from '@ui/display/chip/components/Chip';
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;
@ -17,7 +16,6 @@ export type EntityChipProps = {
variant?: EntityChipVariant;
LeftIcon?: IconComponent;
className?: string;
maxWidth?: number;
};
export enum EntityChipVariant {
@ -34,7 +32,6 @@ export const EntityChip = ({
variant = EntityChipVariant.Regular,
LeftIcon,
className,
maxWidth,
}: EntityChipProps) => {
const theme = useTheme();
@ -70,8 +67,6 @@ export const EntityChip = ({
clickable={!!linkToEntity}
onClick={handleLinkClick}
className={className}
maxWidth={maxWidth}
to={linkToEntity}
/>
);
};

View File

@ -0,0 +1,20 @@
.main {
font-family: inherit;
font-size: inherit;
font-weight: inherit;
max-width: 100%;
overflow: hidden;
text-decoration: inherit;
text-overflow: ellipsis;
white-space: nowrap;
}
.cursor {
cursor: pointer;
}
.large {
height: calc(var(--twentycrm-spacing-multiplicator) * 4px);
}

View File

@ -1,41 +1,40 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import styled from '@emotion/styled';
import clsx from 'clsx';
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;
`;
import styles from './OverflowingTextWithTooltip.module.css';
export const OverflowingTextWithTooltip = ({
size = 'small',
text,
className,
mutliline,
}: {
size?: 'large' | 'small';
text: string | null | undefined;
className?: string;
mutliline?: boolean;
}) => {
const textElementId = `title-id-${uuidV4()}`;
const [textElement, setTextElement] = useState<HTMLDivElement | null>(null);
const isTitleOverflowing =
(text?.length ?? 0) > 0 &&
!!textElement &&
(textElement.scrollHeight > textElement.clientHeight ||
textElement.scrollWidth > textElement.clientWidth);
const textRef = useRef<HTMLDivElement>(null);
const [isTitleOverflowing, setIsTitleOverflowing] = useState(false);
const handleMouseEnter = () => {
const isOverflowing =
(text?.length ?? 0) > 0 && textRef.current
? textRef.current?.scrollHeight > textRef.current?.clientHeight ||
textRef.current.scrollWidth > textRef.current.clientWidth
: false;
setIsTitleOverflowing(isOverflowing);
};
const handleMouseLeave = () => {
setIsTitleOverflowing(false);
};
const handleTooltipClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation();
@ -44,23 +43,29 @@ export const OverflowingTextWithTooltip = ({
return (
<>
<StyledOverflowingText
<div
data-testid="tooltip"
className={className}
ref={setTextElement}
className={clsx({
[styles.main]: true,
[styles.cursor]: isTitleOverflowing,
[styles.large]: size === 'large',
})}
ref={textRef}
id={textElementId}
cursorPointer={isTitleOverflowing}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{text}
</StyledOverflowingText>
</div>
{isTitleOverflowing &&
createPortal(
<div onClick={handleTooltipClick}>
<AppTooltip
anchorSelect={`#${textElementId}`}
content={mutliline ? undefined : text ?? ''}
delayHide={0}
delayHide={1}
offset={5}
isOpen
noArrow
place="bottom"
positionStrategy="absolute"

View File

@ -1,7 +1,7 @@
export const BORDER_COMMON = {
radius: {
xs: '2px',
sm: '4px',
sm: 'var(--twentycrm-border-radius-sm)',
md: '8px',
xl: '20px',
pill: '999px',

View File

@ -10,7 +10,7 @@ export const FONT_COMMON = {
},
weight: {
regular: 400,
medium: 500,
medium: 'var(--twentycrm-border-radius-sm)',
semiBold: 600,
},
family: 'Inter, sans-serif',

View File

@ -1,15 +1,21 @@
// ThemeProvider.tsx
import * as React from 'react';
import { ReactNode, useEffect } from 'react';
import { ThemeProvider as EmotionThemeProvider } from '@emotion/react';
import { ThemeType } from '..';
import './theme.css';
type ThemeProviderProps = {
theme: ThemeType;
children: React.ReactNode;
children: ReactNode;
};
const ThemeProvider: React.FC<ThemeProviderProps> = ({ theme, children }) => {
const ThemeProvider = ({ theme, children }: ThemeProviderProps) => {
useEffect(() => {
document.documentElement.className =
theme.name === 'dark' ? 'dark' : 'light';
}, [theme]);
return <EmotionThemeProvider theme={theme}>{children}</EmotionThemeProvider>;
};

View File

@ -0,0 +1,85 @@
:root {
--twentycrm-spacing-multiplicator: 4;
--twentycrm-border-radius-sm: 4px;
--twentycrm-font-weight-medium: 500;
/* Grays */
--twentycrm-gray-100: #000000;
--twentycrm-gray-100-4: #0000000A;
--twentycrm-gray-100-10: #00000019;
--twentycrm-gray-100-16: #00000029;
--twentycrm-gray-90: #141414;
--twentycrm-gray-85: #171717;
--twentycrm-gray-85-80: #171717CC;
--twentycrm-gray-80: #1b1b1b;
--twentycrm-gray-80-80: #1b1b1bCC;
--twentycrm-gray-75: #1d1d1d;
--twentycrm-gray-70: #222222;
--twentycrm-gray-65: #292929;
--twentycrm-gray-60: #333333;
--twentycrm-gray-55: #4c4c4c;
--twentycrm-gray-50: #666666;
--twentycrm-gray-45: #818181;
--twentycrm-gray-40: #999999;
--twentycrm-gray-35: #b3b3b3;
--twentycrm-gray-30: #cccccc;
--twentycrm-gray-25: #d6d6d6;
--twentycrm-gray-20: #ebebeb;
--twentycrm-gray-15: #f1f1f1;
--twentycrm-gray-10: #fcfcfc;
--twentycrm-gray-10-80: #fcfcfcCC;
--twentycrm-gray-0: #ffffff;
--twentycrm-gray-0-6: #ffffff0f;
--twentycrm-gray-0-10: #ffffff19;
--twentycrm-gray-0-14: #ffffff23;
/* Blues */
--twentycrm-blue-accent-90: #141a25,
--twentycrm-blue-accent-10: #f5f9fd,
}
:root.dark {
/* Accent color */
--twentycrm-accent-quaternary: var(--twentycrm-blue-accent-90);
/* Font color */
--twentycrm-font-color-secondary: var(--twentycrm-gray-35);
--twentycrm-font-color-primary: var(--twentycrm-gray-20);
--twentycrm-font-color-light: var(--twentycrm-gray-50);
--twentycrm-font-color-extra-light: var(--twentycrm-gray-55);
/* Background color */
--twentycrm-background-primary: var(--twentycrm-gray-85);
/* Background transparent color */
--twentycrm-background-transparent-secondary: var(--twentycrm-gray-80-80);
--twentycrm-background-transparent-light: var(--twentycrm-gray-0-6);
--twentycrm-background-transparent-medium: var(--twentycrm-gray-0-10);
--twentycrm-background-transparent-strong: var(--twentycrm-gray-0-14);
/* Border color */
--twentycrm-border-color-medium: var(--twentycrm-gray-65);
}
:root.light {
/* Accent color */
--twentycrm-accent-quaternary: var(--twentycrm-blue-accent-10);
/* Colors */
--twentycrm-font-color-primary: var(--twentycrm-gray-60);
--twentycrm-font-color-secondary: var(--twentycrm-gray-50);
--twentycrm-font-color-light: var(--twentycrm-gray-35);
--twentycrm-font-color-extra-light: var(--twentycrm-gray-30);
/* Background color */
--twentycrm-background-primary: var(--twentycrm-gray-0);
/* Background transparent color */
--twentycrm-background-transparent-secondary: var(--twentycrm-gray-10-80);
--twentycrm-background-transparent-light: var(--twentycrm-gray-100-4);
--twentycrm-background-transparent-medium: var(--twentycrm-gray-100-10);
--twentycrm-background-transparent-strong: var(--twentycrm-gray-100-16);
/* Border color */
--twentycrm-border-color-medium: var(--twentycrm-gray-20);
}