@ -1,6 +1,6 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { IconComponent } from 'twenty-ui';
|
||||
import { IconComponent, OverflowingTextWithTooltip } from 'twenty-ui';
|
||||
|
||||
import { ThemeColor } from '@/ui/theme/constants/MainColorNames';
|
||||
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema';
|
||||
@ -69,7 +69,9 @@ export const Tag = ({
|
||||
<Icon size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
|
||||
</StyledIconContainer>
|
||||
)}
|
||||
<StyledContent>{text}</StyledContent>
|
||||
<StyledContent>
|
||||
<OverflowingTextWithTooltip text={text} />
|
||||
</StyledContent>
|
||||
</StyledTag>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { MouseEventHandler, useMemo } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
|
||||
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata';
|
||||
import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
|
||||
import {
|
||||
ExpandableList,
|
||||
ExpandableListProps,
|
||||
} from '@/ui/layout/expandable-list/components/ExpandableList';
|
||||
import { RoundedLink } from '@/ui/navigation/link/components/RoundedLink';
|
||||
import {
|
||||
LinkType,
|
||||
@ -13,16 +15,19 @@ import { isDefined } from '~/utils/isDefined';
|
||||
import { getAbsoluteUrl } from '~/utils/url/getAbsoluteUrl';
|
||||
import { getUrlHostName } from '~/utils/url/getUrlHostName';
|
||||
|
||||
const StyledContainer = styled(EllipsisDisplay)`
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
type LinksDisplayProps = {
|
||||
type LinksDisplayProps = Pick<
|
||||
ExpandableListProps,
|
||||
'anchorElement' | 'isChipCountDisplayed' | 'withExpandedListBorder'
|
||||
> & {
|
||||
value?: FieldLinksValue;
|
||||
};
|
||||
|
||||
export const LinksDisplay = ({ value }: LinksDisplayProps) => {
|
||||
export const LinksDisplay = ({
|
||||
anchorElement,
|
||||
isChipCountDisplayed,
|
||||
withExpandedListBorder,
|
||||
value,
|
||||
}: LinksDisplayProps) => {
|
||||
const links = useMemo(
|
||||
() =>
|
||||
[
|
||||
@ -49,7 +54,11 @@ export const LinksDisplay = ({ value }: LinksDisplayProps) => {
|
||||
const handleClick: MouseEventHandler = (event) => event.stopPropagation();
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<ExpandableList
|
||||
anchorElement={anchorElement}
|
||||
isChipCountDisplayed={isChipCountDisplayed}
|
||||
withExpandedListBorder={withExpandedListBorder}
|
||||
>
|
||||
{links.map(({ url, label, type }, index) =>
|
||||
type === LinkType.LinkedIn || type === LinkType.Twitter ? (
|
||||
<SocialLink key={index} href={url} onClick={handleClick} type={type}>
|
||||
@ -61,6 +70,6 @@ export const LinksDisplay = ({ value }: LinksDisplayProps) => {
|
||||
</RoundedLink>
|
||||
),
|
||||
)}
|
||||
</StyledContainer>
|
||||
</ExpandableList>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,57 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@ -1,48 +1,49 @@
|
||||
import { ReactElement, useEffect, useState } from 'react';
|
||||
import { ReactElement, useCallback, useEffect, useRef, 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;
|
||||
import { ExpandedListDropdown } from '@/ui/layout/expandable-list/components/ExpandedListDropdown';
|
||||
import { isFirstOverflowingChildElement } from '@/ui/layout/expandable-list/utils/isFirstOverflowingChildElement';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
justify-content: space-between;
|
||||
min-width: 100%;
|
||||
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}';
|
||||
const StyledChildrenContainer = styled.div`
|
||||
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'};
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
flex: 0 1 fit-content;
|
||||
position: relative; // Needed so children elements compute their offsetLeft relatively to this element.
|
||||
`;
|
||||
|
||||
const StyledChildContainer = styled.div`
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
|
||||
&:last-child {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledChipCount = styled(Chip)`
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export type ExpandableListProps = {
|
||||
isHovered?: boolean;
|
||||
reference?: HTMLDivElement;
|
||||
forceDisplayHiddenCount?: boolean;
|
||||
withDropDownBorder?: boolean;
|
||||
anchorElement?: HTMLElement;
|
||||
isChipCountDisplayed?: boolean;
|
||||
withExpandedListBorder?: boolean;
|
||||
};
|
||||
|
||||
export type ChildrenProperty = {
|
||||
@ -52,95 +53,127 @@ export type ChildrenProperty = {
|
||||
|
||||
export const ExpandableList = ({
|
||||
children,
|
||||
isHovered,
|
||||
reference,
|
||||
forceDisplayHiddenCount = false,
|
||||
withDropDownBorder = false,
|
||||
anchorElement,
|
||||
isChipCountDisplayed: isChipCountDisplayedFromProps,
|
||||
withExpandedListBorder = false,
|
||||
}: {
|
||||
children: ReactElement[];
|
||||
} & ExpandableListProps) => {
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [isDropdownMenuOpen, setIsDropdownMenuOpen] = useState(false);
|
||||
const [childrenWidths, setChildrenWidths] = useState<Record<number, number>>(
|
||||
{},
|
||||
// isChipCountDisplayedInternal => uncontrolled display of the chip count.
|
||||
// isChipCountDisplayedFromProps => controlled display of the chip count.
|
||||
// If isChipCountDisplayedFromProps is provided, isChipCountDisplayedInternal is not taken into account.
|
||||
const [isChipCountDisplayedInternal, setIsChipCountDisplayedInternal] =
|
||||
useState(false);
|
||||
const isChipCountDisplayed = isDefined(isChipCountDisplayedFromProps)
|
||||
? isChipCountDisplayedFromProps
|
||||
: isChipCountDisplayedInternal;
|
||||
|
||||
const [isListExpanded, setIsListExpanded] = useState(false);
|
||||
|
||||
// Used with floating-ui if anchorElement is not provided.
|
||||
// floating-ui mentions that `useState` must be used instead of `useRef`
|
||||
// @see https://floating-ui.com/docs/useFloating#elements
|
||||
const [childrenContainerElement, setChildrenContainerElement] =
|
||||
useState<HTMLDivElement | null>(null);
|
||||
const [previousChildrenContainerWidth, setPreviousChildrenContainerWidth] =
|
||||
useState(childrenContainerElement?.clientWidth ?? 0);
|
||||
|
||||
// Used with useListenClickOutside.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [firstHiddenChildIndex, setFirstHiddenChildIndex] = useState(
|
||||
children.length,
|
||||
);
|
||||
|
||||
// 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 hiddenChildrenCount = children.length - firstHiddenChildIndex;
|
||||
const canDisplayChipCount = isChipCountDisplayed && hiddenChildrenCount > 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) => {
|
||||
const handleChipCountClick = useCallback((event: React.MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
setIsDropdownMenuOpen(true);
|
||||
};
|
||||
setIsListExpanded(true);
|
||||
}, []);
|
||||
|
||||
const resetFirstHiddenChildIndex = useCallback(() => {
|
||||
setFirstHiddenChildIndex(children.length);
|
||||
}, [children.length]);
|
||||
|
||||
// Recompute first hidden child when:
|
||||
// - isChipCountDisplayed changes
|
||||
// - children length changes
|
||||
useEffect(() => {
|
||||
if (!isHovered) {
|
||||
setIsDropdownMenuOpen(false);
|
||||
}
|
||||
}, [isHovered]);
|
||||
resetFirstHiddenChildIndex();
|
||||
}, [isChipCountDisplayed, children.length, resetFirstHiddenChildIndex]);
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [containerRef],
|
||||
callback: () => {
|
||||
// Handle container resize
|
||||
if (
|
||||
childrenContainerElement?.clientWidth !== previousChildrenContainerWidth
|
||||
) {
|
||||
resetFirstHiddenChildIndex();
|
||||
setPreviousChildrenContainerWidth(
|
||||
childrenContainerElement?.clientWidth ?? 0,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledContainer
|
||||
ref={(el) => {
|
||||
if (!el) return;
|
||||
setContainerWidth(el.getBoundingClientRect().width);
|
||||
}}
|
||||
ref={containerRef}
|
||||
onMouseEnter={
|
||||
isChipCountDisplayedFromProps
|
||||
? undefined
|
||||
: () => setIsChipCountDisplayedInternal(true)
|
||||
}
|
||||
onMouseLeave={
|
||||
isChipCountDisplayedFromProps
|
||||
? undefined
|
||||
: () => setIsChipCountDisplayedInternal(false)
|
||||
}
|
||||
>
|
||||
<ChildrenContainer
|
||||
childrenProperties={childrenProperties}
|
||||
setChildrenWidths={setChildrenWidths}
|
||||
isFocusedMode={isFocusedMode}
|
||||
>
|
||||
{children}
|
||||
</ChildrenContainer>
|
||||
{displayHiddenCountChip && (
|
||||
<StyledChildrenContainer ref={setChildrenContainerElement}>
|
||||
{children.slice(0, firstHiddenChildIndex).map((child, index) => (
|
||||
<StyledChildContainer
|
||||
key={index}
|
||||
ref={(childElement) => {
|
||||
if (
|
||||
// First element is always displayed.
|
||||
index > 0 &&
|
||||
isFirstOverflowingChildElement({
|
||||
containerElement: childrenContainerElement,
|
||||
childElement,
|
||||
})
|
||||
) {
|
||||
setFirstHiddenChildIndex(index);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{child}
|
||||
</StyledChildContainer>
|
||||
))}
|
||||
</StyledChildrenContainer>
|
||||
{canDisplayChipCount && (
|
||||
<AnimatedContainer>
|
||||
<Chip
|
||||
<StyledChipCount
|
||||
label={`+${hiddenChildrenCount}`}
|
||||
variant={ChipVariant.Highlighted}
|
||||
onClick={openDropdownMenu}
|
||||
onClick={handleChipCountClick}
|
||||
/>
|
||||
</AnimatedContainer>
|
||||
)}
|
||||
{isDropdownMenuOpen && (
|
||||
<DropdownMenu
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
width={
|
||||
reference
|
||||
? Math.max(220, reference.getBoundingClientRect().width)
|
||||
: undefined
|
||||
}
|
||||
{isListExpanded && (
|
||||
<ExpandedListDropdown
|
||||
anchorElement={anchorElement ?? childrenContainerElement ?? undefined}
|
||||
onClickOutside={() => {
|
||||
resetFirstHiddenChildIndex();
|
||||
setIsListExpanded(false);
|
||||
}}
|
||||
withBorder={withExpandedListBorder}
|
||||
>
|
||||
<StyledRelationsListContainer withDropDownBorder={withDropDownBorder}>
|
||||
{children}
|
||||
</StyledRelationsListContainer>
|
||||
</DropdownMenu>
|
||||
{children}
|
||||
</ExpandedListDropdown>
|
||||
)}
|
||||
</StyledContainer>
|
||||
);
|
||||
|
||||
@ -0,0 +1,69 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { offset, useFloating } from '@floating-ui/react';
|
||||
|
||||
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu';
|
||||
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
|
||||
|
||||
type ExpandedListDropdownProps = {
|
||||
anchorElement?: HTMLElement;
|
||||
children: ReactNode;
|
||||
onClickOutside?: () => void;
|
||||
withBorder?: boolean;
|
||||
};
|
||||
|
||||
const StyledExpandedListContainer = styled.div<{
|
||||
withBorder?: boolean;
|
||||
}>`
|
||||
backdrop-filter: ${({ theme }) => theme.blur.strong};
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
box-shadow: ${({ theme }) =>
|
||||
`0px 2px 4px ${theme.boxShadow.light}, 2px 4px 16px ${theme.boxShadow.strong}`};
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
|
||||
${({ theme, withBorder }) =>
|
||||
withBorder &&
|
||||
css`
|
||||
outline: 1px solid ${theme.font.color.extraLight};
|
||||
`};
|
||||
`;
|
||||
|
||||
export const ExpandedListDropdown = ({
|
||||
anchorElement,
|
||||
children,
|
||||
onClickOutside,
|
||||
withBorder,
|
||||
}: ExpandedListDropdownProps) => {
|
||||
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: anchorElement },
|
||||
});
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [refs.floating],
|
||||
callback: onClickOutside ?? (() => {}),
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
ref={refs.setFloating}
|
||||
style={floatingStyles}
|
||||
width={
|
||||
anchorElement
|
||||
? Math.max(220, anchorElement.getBoundingClientRect().width)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<StyledExpandedListContainer withBorder={withBorder}>
|
||||
{children}
|
||||
</StyledExpandedListContainer>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@ -1,13 +1,11 @@
|
||||
import { ReactElement, useRef, useState } from 'react';
|
||||
import styled from '@emotion/styled';
|
||||
import { expect } from '@storybook/jest';
|
||||
import { Meta, StoryObj } from '@storybook/react';
|
||||
import { userEvent, within } from '@storybook/test';
|
||||
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 { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
|
||||
import { MAIN_COLOR_NAMES } from '@/ui/theme/constants/MainColorNames';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
@ -15,60 +13,55 @@ const StyledContainer = styled.div`
|
||||
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],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<StyledContainer>
|
||||
<Story />
|
||||
</StyledContainer>
|
||||
),
|
||||
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,
|
||||
children: Array.from({ length: 7 }, (_, index) => (
|
||||
<Tag
|
||||
key={index}
|
||||
text={`Option ${index + 1}`}
|
||||
color={MAIN_COLOR_NAMES[index]}
|
||||
/>
|
||||
)),
|
||||
isChipCountDisplayed: false,
|
||||
},
|
||||
argTypes: {
|
||||
children: { control: false },
|
||||
isHovered: { control: false },
|
||||
reference: { control: false },
|
||||
anchorElement: { control: false },
|
||||
},
|
||||
render: Render,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ExpandableList>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const WithChipCount: Story = {
|
||||
args: { isChipCountDisplayed: true },
|
||||
};
|
||||
|
||||
export const WithExpandedList: Story = {
|
||||
...WithChipCount,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const chipCount = await canvas.findByText('+3');
|
||||
|
||||
await userEvent.click(chipCount);
|
||||
|
||||
expect(await canvas.findByText('Option 7')).toBeDefined();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithExpandedListBorder: Story = {
|
||||
...WithExpandedList,
|
||||
args: { ...WithExpandedList.args, withExpandedListBorder: true },
|
||||
};
|
||||
|
||||
@ -1,41 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,13 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@ -1,30 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@ -1,6 +0,0 @@
|
||||
export const getChipContentWidth = (numberOfChildren: number) => {
|
||||
if (numberOfChildren <= 1) {
|
||||
return 0;
|
||||
}
|
||||
return 17 + 8 * Math.trunc(Math.log10(numberOfChildren));
|
||||
};
|
||||
@ -0,0 +1,17 @@
|
||||
import { isDefined } from '~/utils/isDefined';
|
||||
|
||||
export const isFirstOverflowingChildElement = ({
|
||||
containerElement,
|
||||
childElement,
|
||||
}: {
|
||||
containerElement: HTMLElement | null;
|
||||
childElement: HTMLElement | null;
|
||||
}) =>
|
||||
isDefined(containerElement) &&
|
||||
isDefined(childElement) &&
|
||||
// First element is always displayed.
|
||||
isDefined(childElement.previousElementSibling) &&
|
||||
containerElement.scrollWidth > containerElement.clientWidth &&
|
||||
childElement.offsetLeft > containerElement.clientWidth &&
|
||||
(childElement.previousElementSibling as HTMLElement).offsetLeft <
|
||||
containerElement.clientWidth;
|
||||
@ -10,22 +10,15 @@ type RoundedLinkProps = {
|
||||
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
|
||||
};
|
||||
|
||||
const StyledClickable = styled.div`
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
overflow: hidden;
|
||||
text-decoration: none;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
const StyledLink = styled(ReactLink)`
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
const StyledChip = styled(Chip)`
|
||||
border-color: ${({ theme }) => theme.border.color.strong};
|
||||
box-sizing: border-box;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
export const RoundedLink = ({
|
||||
@ -33,20 +26,21 @@ export const RoundedLink = ({
|
||||
className,
|
||||
href,
|
||||
onClick,
|
||||
}: RoundedLinkProps) => (
|
||||
<div>
|
||||
{children !== '' ? (
|
||||
<StyledClickable className={className}>
|
||||
<ReactLink target="_blank" to={href} onClick={onClick}>
|
||||
<StyledChip
|
||||
label={`${children}`}
|
||||
variant={ChipVariant.Rounded}
|
||||
size={ChipSize.Small}
|
||||
/>
|
||||
</ReactLink>
|
||||
</StyledClickable>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}: RoundedLinkProps) => {
|
||||
if (!children) return null;
|
||||
|
||||
return (
|
||||
<StyledLink
|
||||
className={className}
|
||||
target="_blank"
|
||||
to={href}
|
||||
onClick={onClick}
|
||||
>
|
||||
<StyledChip
|
||||
label={`${children}`}
|
||||
variant={ChipVariant.Rounded}
|
||||
size={ChipSize.Small}
|
||||
/>
|
||||
</StyledLink>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user