martmull
2024-05-03 15:03:06 +02:00
committed by GitHub
parent 1351a95754
commit 87994c26ff
51 changed files with 687 additions and 405 deletions

View File

@ -1,104 +0,0 @@
import React, { ReactElement, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import styled from '@emotion/styled';
import { Chip, ChipVariant } from 'twenty-ui';
import { IntersectionObserverWrapper } from '@/ui/display/expandable-list/IntersectionObserverWrapper';
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(1)};
box-sizing: border-box;
white-space: nowrap;
overflow-x: hidden;
`;
const StyledExpendableCell = styled.div`
align-content: center;
align-items: center;
backdrop-filter: ${({ theme }) => theme.blur.strong};
background: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: ${({ theme }) => theme.boxShadow.light};
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(2)};
`;
export const ExpandableList = ({
listItems,
rootRef,
id,
}: {
listItems: ReactElement[];
rootRef: React.RefObject<HTMLElement>;
id: string;
}) => {
const [listItemsInView, setListItemsInView] = useState(new Set<number>());
const firstListItem = listItems[0];
const dropdownId = `expandable-list-dropdown-${id}`;
const containerRef = useRef<HTMLDivElement>(null);
const divRef = useRef<HTMLDivElement>(null);
return (
<StyledContainer ref={containerRef}>
{firstListItem}
{listItems.slice(1).map((listItem, index) => (
<React.Fragment key={index}>
<IntersectionObserverWrapper
set={setListItemsInView}
id={index}
rootRef={rootRef}
>
{listItem}
</IntersectionObserverWrapper>
{index === listItemsInView.size - 1 &&
listItems.length - listItemsInView.size - 1 !== 0 && (
<Dropdown
dropdownId={dropdownId}
dropdownHotkeyScope={{
scope: dropdownId,
}}
clickableComponent={
<Chip
label={`+${listItems.length - listItemsInView.size - 1}`}
variant={ChipVariant.Highlighted}
/>
}
dropdownComponents={
<>
{divRef.current &&
createPortal(
<StyledExpendableCell>
{listItems}
</StyledExpendableCell>,
divRef.current as HTMLElement,
)}
</>
}
/>
)}
</React.Fragment>
))}
<div
ref={divRef}
style={{
position: 'absolute',
top: '100%',
zIndex: 1,
boxSizing: 'border-box',
}}
></div>
</StyledContainer>
);
};

View File

@ -1,47 +0,0 @@
import React from 'react';
import { useInView } from 'react-intersection-observer';
import styled from '@emotion/styled';
const StyledDiv = styled.div<{ inView?: boolean }>`
opacity: ${({ inView }) => (inView === undefined || inView ? 1 : 0)};
`;
export const IntersectionObserverWrapper = ({
set,
id,
rootRef,
children,
}: {
set: React.Dispatch<React.SetStateAction<Set<number>>>;
id: number;
rootRef?: React.RefObject<HTMLElement>;
children: React.ReactNode;
}) => {
const { ref, inView } = useInView({
threshold: 1,
onChange: (inView) => {
if (inView) {
set((prev: Set<number>) => {
const newSet = new Set(prev);
newSet.add(id);
return newSet;
});
}
if (!inView) {
set((prev: Set<number>) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
},
root: rootRef?.current,
rootMargin: '0px 0px -50px 0px',
});
return (
<StyledDiv ref={ref} inView={inView}>
{children}
</StyledDiv>
);
};

View File

@ -3,7 +3,7 @@ import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconInfoCircle } from 'twenty-ui';
import { Button } from '@/ui/input/button/components/Button.tsx';
import { Button } from '@/ui/input/button/components/Button';
export type InfoAccent = 'blue' | 'danger';
export type InfoProps = {

View File

@ -1,7 +1,8 @@
import React from 'react';
import styled from '@emotion/styled';
import { Radio } from '@/ui/input/components/Radio.tsx';
import { Radio } from '@/ui/input/components/Radio';
const StyledSubscriptionCardContainer = styled.button`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};

View File

@ -0,0 +1,57 @@
import { Dispatch, ReactElement, SetStateAction } from 'react';
import styled from '@emotion/styled';
import { ChildrenProperty } from '@/ui/layout/expandable-list/components/ExpandableList';
const StyledChildContainer = styled.div<{
shrink?: number;
isVisible?: boolean;
displayHiddenCount?: boolean;
}>`
display: ${({ isVisible = true }) => (isVisible ? 'flex' : 'none')};
flex-shrink: ${({ shrink = 1 }) => shrink};
overflow: ${({ displayHiddenCount }) =>
displayHiddenCount ? 'hidden' : 'none'};
`;
const StyledChildrenContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
overflow: hidden;
`;
export const ChildrenContainer = ({
children,
childrenProperties,
setChildrenWidths,
isFocusedMode,
}: {
children: ReactElement[];
childrenProperties: Record<number, ChildrenProperty>;
setChildrenWidths: Dispatch<SetStateAction<Record<number, number>>>;
isFocusedMode: boolean;
}) => {
return (
<StyledChildrenContainer>
{children.map((child, index) => {
return (
<StyledChildContainer
ref={(el) => {
if (!el || isFocusedMode) return;
setChildrenWidths((prevState) => {
prevState[index] = el.getBoundingClientRect().width;
return prevState;
});
}}
key={index}
displayHiddenCount={isFocusedMode}
isVisible={childrenProperties[index]?.isVisible}
shrink={childrenProperties[index]?.shrink}
>
{child}
</StyledChildContainer>
);
})}
</StyledChildrenContainer>
);
};

View File

@ -0,0 +1,147 @@
import { ReactElement, useEffect, useState } from 'react';
import styled from '@emotion/styled';
import { offset, useFloating } from '@floating-ui/react';
import { Chip, ChipVariant } from 'twenty-ui';
import { AnimatedContainer } from '@/object-record/record-table/components/AnimatedContainer';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
import { ChildrenContainer } from '@/ui/layout/expandable-list/components/ChildrenContainer';
import { getChildrenProperties } from '@/ui/layout/expandable-list/utils/getChildProperties';
import { getChipContentWidth } from '@/ui/layout/expandable-list/utils/getChipContentWidth';
export const GAP_WIDTH = 4;
const StyledContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: space-between;
width: 100%;
`;
const StyledRelationsListContainer = styled.div<{
withDropDownBorder?: boolean;
}>`
backdrop-filter: ${({ theme }) => theme.blur.strong};
background-color: ${({ theme }) => theme.background.secondary};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-shadow: '0px 2px 4px ${({ theme }) =>
theme.boxShadow.light}, 2px 4px 16px ${({ theme }) =>
theme.boxShadow.strong}';
display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(2)};
outline: ${(props) =>
props.withDropDownBorder
? `1px solid ${props.theme.font.color.extraLight}`
: 'none'};
`;
export type ExpandableListProps = {
isHovered?: boolean;
reference?: HTMLDivElement;
forceDisplayHiddenCount?: boolean;
withDropDownBorder?: boolean;
};
export type ChildrenProperty = {
shrink: number;
isVisible: boolean;
};
export const ExpandableList = ({
children,
isHovered,
reference,
forceDisplayHiddenCount = false,
withDropDownBorder = false,
}: {
children: ReactElement[];
} & ExpandableListProps) => {
const [containerWidth, setContainerWidth] = useState(0);
const [isDropdownMenuOpen, setIsDropdownMenuOpen] = useState(false);
const [childrenWidths, setChildrenWidths] = useState<Record<number, number>>(
{},
);
// Because Chip width depends on the number of hidden children which depends on the Chip width, we have a circular dependency
// To avoid it, we set the Chip width and make sure it can display its content (a number greater than 1)
const chipContentWidth = getChipContentWidth(children.length);
const chipContainerWidth = chipContentWidth + 2 * GAP_WIDTH; // Because Chip component has 4px padding-left and right
const availableWidth = containerWidth - (chipContainerWidth + GAP_WIDTH); // Because there is a 4px gap between ChildrenContainer and ChipContainer
const isFocusedMode =
(isHovered || forceDisplayHiddenCount) &&
Object.values(childrenWidths).length > 0;
const childrenProperties = getChildrenProperties(
isFocusedMode,
availableWidth,
childrenWidths,
);
const hiddenChildrenCount = Object.values(childrenProperties).filter(
(childProperties) => !childProperties.isVisible,
).length;
const displayHiddenCountChip = isFocusedMode && hiddenChildrenCount > 0;
const { refs, floatingStyles } = useFloating({
// @ts-expect-error placement accepts 'start' as value even if the typing does not permit it
placement: 'start',
middleware: [offset({ mainAxis: -1, crossAxis: -1 })],
elements: { reference },
});
const openDropdownMenu = (event: React.MouseEvent) => {
event.stopPropagation();
setIsDropdownMenuOpen(true);
};
useEffect(() => {
if (!isHovered) {
setIsDropdownMenuOpen(false);
}
}, [isHovered]);
return (
<StyledContainer
ref={(el) => {
if (!el) return;
setContainerWidth(el.getBoundingClientRect().width);
}}
>
<ChildrenContainer
childrenProperties={childrenProperties}
setChildrenWidths={setChildrenWidths}
isFocusedMode={isFocusedMode}
>
{children}
</ChildrenContainer>
{displayHiddenCountChip && (
<AnimatedContainer>
<Chip
label={`+${hiddenChildrenCount}`}
variant={ChipVariant.Highlighted}
onClick={openDropdownMenu}
/>
</AnimatedContainer>
)}
{isDropdownMenuOpen && (
<DropdownMenu
ref={refs.setFloating}
style={floatingStyles}
width={
reference
? Math.max(220, reference.getBoundingClientRect().width)
: undefined
}
>
<StyledRelationsListContainer withDropDownBorder={withDropDownBorder}>
{children}
</StyledRelationsListContainer>
</DropdownMenu>
)}
</StyledContainer>
);
};

View File

@ -0,0 +1,74 @@
import { ReactElement, useRef, useState } from 'react';
import styled from '@emotion/styled';
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'packages/twenty-ui';
import { Tag } from '@/ui/display/tag/components/Tag';
import {
ExpandableList,
ExpandableListProps,
} from '@/ui/layout/expandable-list/components/ExpandableList';
import { MAIN_COLOR_NAMES } from '@/ui/theme/constants/MainColorNames';
const StyledContainer = styled.div`
padding: ${({ theme }) => theme.spacing(1)};
width: 300px;
`;
type RenderProps = ExpandableListProps & {
children: ReactElement[];
};
const Render = (args: RenderProps) => {
const [isHovered, setIsHovered] = useState(false);
const reference = useRef<HTMLDivElement>(null);
return (
<StyledContainer
ref={reference}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<ExpandableList
reference={reference.current || undefined}
forceDisplayHiddenCount={args.forceDisplayHiddenCount}
withDropDownBorder={args.withDropDownBorder}
isHovered={isHovered}
>
{args.children}
</ExpandableList>
</StyledContainer>
);
};
const meta: Meta<typeof ExpandableList> = {
title: 'UI/Layout/ExpandableList/ExpandableList',
component: ExpandableList,
decorators: [ComponentDecorator],
args: {
children: [
<Tag key={1} text={'Option 1'} color={MAIN_COLOR_NAMES[0]} />,
<Tag key={2} text={'Option 2'} color={MAIN_COLOR_NAMES[1]} />,
<Tag key={3} text={'Option 3'} color={MAIN_COLOR_NAMES[2]} />,
<Tag key={4} text={'Option 4'} color={MAIN_COLOR_NAMES[3]} />,
<Tag key={5} text={'Option 5'} color={MAIN_COLOR_NAMES[4]} />,
<Tag key={6} text={'Option 6'} color={MAIN_COLOR_NAMES[5]} />,
<Tag key={7} text={'Option 7'} color={MAIN_COLOR_NAMES[6]} />,
],
isHovered: undefined,
reference: undefined,
forceDisplayHiddenCount: false,
withDropDownBorder: false,
},
argTypes: {
children: { control: false },
isHovered: { control: false },
reference: { control: false },
},
render: Render,
};
export default meta;
type Story = StoryObj<typeof ExpandableList>;
export const Default: Story = {};

View File

@ -0,0 +1,41 @@
import { getChildrenProperties } from '@/ui/layout/expandable-list/utils/getChildProperties';
describe('getChildrenProperties', () => {
it('should return default value when isFocused is False', () => {
const isFocused = false;
const availableWidth = 100;
expect(getChildrenProperties(isFocused, availableWidth, {})).toEqual({});
expect(
getChildrenProperties(isFocused, availableWidth, { 0: 40, 1: 40 }),
).toEqual({});
});
it('should return proper value when isFocused is True', () => {
const isFocused = true;
const availableWidth = 100;
expect(getChildrenProperties(isFocused, availableWidth, {})).toEqual({});
expect(
getChildrenProperties(isFocused, availableWidth, { 0: 40, 1: 40 }),
).toEqual({
0: { shrink: 0, isVisible: true },
1: { shrink: 0, isVisible: true },
});
expect(
getChildrenProperties(isFocused, availableWidth, {
0: 40,
1: 40,
2: 40,
3: 40,
4: 40,
}),
).toEqual({
0: { shrink: 0, isVisible: true },
1: { shrink: 0, isVisible: true },
2: { shrink: 1, isVisible: true },
3: { shrink: 1, isVisible: false },
4: { shrink: 1, isVisible: false },
});
});
});

View File

@ -0,0 +1,13 @@
import { getChipContentWidth } from '@/ui/layout/expandable-list/utils/getChipContentWidth';
describe('getChipContentWidth', () => {
it('should return proper value', () => {
expect(getChipContentWidth(0)).toEqual(0);
expect(getChipContentWidth(1)).toEqual(0);
expect(getChipContentWidth(2)).toEqual(17);
expect(getChipContentWidth(20)).toEqual(25);
expect(getChipContentWidth(200)).toEqual(33);
expect(getChipContentWidth(2000)).toEqual(41);
expect(getChipContentWidth(20000)).toEqual(49);
});
});

View File

@ -0,0 +1,30 @@
import {
ChildrenProperty,
GAP_WIDTH,
} from '@/ui/layout/expandable-list/components/ExpandableList';
export const getChildrenProperties = (
isFocusedMode: boolean,
availableWidth: number,
childrenWidths: Record<number, number>,
) => {
if (!isFocusedMode) {
return {};
}
let cumulatedChildrenWidth = 0;
const result: Record<number, ChildrenProperty> = {};
Object.values(childrenWidths).forEach((width, index) => {
// Because there is a 4px gap between children
const childWidth = width + GAP_WIDTH;
let shrink = 1;
let isVisible = true;
if (cumulatedChildrenWidth > availableWidth) {
isVisible = false;
} else if (cumulatedChildrenWidth + childWidth <= availableWidth) {
shrink = 0;
}
result[index] = { shrink, isVisible };
cumulatedChildrenWidth += childWidth;
});
return result;
};

View File

@ -0,0 +1,6 @@
export const getChipContentWidth = (numberOfChildren: number) => {
if (numberOfChildren <= 1) {
return 0;
}
return 17 + 8 * Math.trunc(Math.log10(numberOfChildren));
};

View File

@ -6,6 +6,7 @@ import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener';
import { isRightDrawerAnimationCompletedState } from '@/ui/layout/right-drawer/states/isRightDrawerAnimationCompleted';
import { rightDrawerCloseEventState } from '@/ui/layout/right-drawer/states/rightDrawerCloseEventsState';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener';
@ -46,6 +47,9 @@ export const RightDrawer = () => {
);
const isRightDrawerExpanded = useRecoilValue(isRightDrawerExpandedState);
const [, setIsRightDrawerAnimationCompleted] = useRecoilState(
isRightDrawerAnimationCompletedState,
);
const rightDrawerPage = useRecoilValue(rightDrawerPageState);
@ -112,6 +116,9 @@ export const RightDrawer = () => {
x: '100%',
},
};
const handleAnimationComplete = () => {
setIsRightDrawerAnimationCompleted(isRightDrawerOpen);
};
return (
<StyledContainer
@ -121,6 +128,7 @@ export const RightDrawer = () => {
transition={{
duration: theme.animation.duration.normal,
}}
onAnimationComplete={handleAnimationComplete}
>
<StyledRightDrawer ref={rightDrawerRef}>
{isRightDrawerOpen && <RightDrawerRouter />}

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const isRightDrawerAnimationCompletedState = createState<boolean>({
key: 'isRightDrawerAnimationCompletedState',
defaultValue: false,
});

View File

@ -1,7 +1,7 @@
import { useTheme } from '@emotion/react';
import { IconBrandGithub } from 'twenty-ui';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink.tsx';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink';
import packageJson from '../../../../../../package.json';
import { GITHUB_LINK } from '../constants/GithubLink';

View File

@ -1,7 +1,7 @@
import { Meta, StoryObj } from '@storybook/react';
import { ComponentDecorator } from 'twenty-ui';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink.tsx';
import { ActionLink } from '@/ui/navigation/link/components/ActionLink';
const meta: Meta<typeof ActionLink> = {
title: 'UI/navigation/link/ActionLink',