feat: display Links field as Expandable List (#5374)

Closes #5114
This commit is contained in:
Thaïs
2024-05-15 15:52:23 +02:00
committed by GitHub
parent 38eb293b3c
commit 602d5422a2
23 changed files with 440 additions and 454 deletions

View File

@ -1,4 +1,4 @@
import { useRef, useState } from 'react'; import { useRef } from 'react';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
@ -88,7 +88,6 @@ export const CalendarEventParticipantsResponseStatusField = ({
]; ];
const participantsContainerRef = useRef<HTMLDivElement>(null); const participantsContainerRef = useRef<HTMLDivElement>(null);
const [isHovered, setIsHovered] = useState(false);
const styledChips = orderedParticipants.map((participant, index) => ( const styledChips = orderedParticipants.map((participant, index) => (
<ParticipantChip key={index} participant={participant} /> <ParticipantChip key={index} participant={participant} />
)); ));
@ -103,16 +102,11 @@ export const CalendarEventParticipantsResponseStatusField = ({
<EllipsisDisplay>{responseStatus}</EllipsisDisplay> <EllipsisDisplay>{responseStatus}</EllipsisDisplay>
</StyledLabelContainer> </StyledLabelContainer>
</StyledLabelAndIconContainer> </StyledLabelAndIconContainer>
<StyledDiv <StyledDiv ref={participantsContainerRef}>
ref={participantsContainerRef}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{isRightDrawerAnimationCompleted && ( {isRightDrawerAnimationCompleted && (
<ExpandableList <ExpandableList
isHovered={isHovered} anchorElement={participantsContainerRef.current || undefined}
reference={participantsContainerRef.current || undefined} isChipCountDisplayed
forceDisplayHiddenCount
> >
{styledChips} {styledChips}
</ExpandableList> </ExpandableList>

View File

@ -2,10 +2,13 @@ import styled from '@emotion/styled';
import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject'; import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject';
import { RecordChip } from '@/object-record/components/RecordChip'; import { RecordChip } from '@/object-record/components/RecordChip';
import { import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
ExpandableList,
ExpandableListProps, type ActivityTargetChipsProps = {
} from '@/ui/layout/expandable-list/components/ExpandableList'; activityTargetObjectRecords: ActivityTargetWithTargetRecord[];
anchorElement?: HTMLElement;
maxWidth?: number;
};
const StyledContainer = styled.div<{ maxWidth?: number }>` const StyledContainer = styled.div<{ maxWidth?: number }>`
display: flex; display: flex;
@ -16,20 +19,12 @@ const StyledContainer = styled.div<{ maxWidth?: number }>`
export const ActivityTargetChips = ({ export const ActivityTargetChips = ({
activityTargetObjectRecords, activityTargetObjectRecords,
isHovered, anchorElement,
reference,
maxWidth, maxWidth,
}: { }: ActivityTargetChipsProps) => {
activityTargetObjectRecords: ActivityTargetWithTargetRecord[];
maxWidth?: number;
} & ExpandableListProps) => {
return ( return (
<StyledContainer maxWidth={maxWidth}> <StyledContainer maxWidth={maxWidth}>
<ExpandableList <ExpandableList anchorElement={anchorElement} isChipCountDisplayed>
isHovered={isHovered}
reference={reference}
forceDisplayHiddenCount
>
{activityTargetObjectRecords.map( {activityTargetObjectRecords.map(
(activityTargetObjectRecord, index) => ( (activityTargetObjectRecord, index) => (
<RecordChip <RecordChip

View File

@ -53,12 +53,13 @@ export const ActivityTargetsInlineCell = ({
/> />
} }
label="Relations" label="Relations"
displayModeContent={ displayModeContent={({ cellElement }) => (
<ActivityTargetChips <ActivityTargetChips
anchorElement={cellElement}
activityTargetObjectRecords={activityTargetObjectRecords} activityTargetObjectRecords={activityTargetObjectRecords}
maxWidth={maxWidth} maxWidth={maxWidth}
/> />
} )}
isDisplayModeContentEmpty={activityTargetObjectRecords.length === 0} isDisplayModeContentEmpty={activityTargetObjectRecords.length === 0}
/> />
</RecordFieldInputScope> </RecordFieldInputScope>

View File

@ -3,7 +3,6 @@ import { useContext } from 'react';
import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay'; import { LinksFieldDisplay } from '@/object-record/record-field/meta-types/display/components/LinksFieldDisplay';
import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone'; import { isFieldDisplayedAsPhone } from '@/object-record/record-field/types/guards/isFieldDisplayedAsPhone';
import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks'; import { isFieldLinks } from '@/object-record/record-field/types/guards/isFieldLinks';
import { ExpandableListProps } from '@/ui/layout/expandable-list/components/ExpandableList';
import { FieldContext } from '../contexts/FieldContext'; import { FieldContext } from '../contexts/FieldContext';
import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay'; import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay';
@ -38,13 +37,17 @@ import { isFieldSelect } from '../types/guards/isFieldSelect';
import { isFieldText } from '../types/guards/isFieldText'; import { isFieldText } from '../types/guards/isFieldText';
import { isFieldUuid } from '../types/guards/isFieldUuid'; import { isFieldUuid } from '../types/guards/isFieldUuid';
type FieldDisplayProps = ExpandableListProps; type FieldDisplayProps = {
isCellSoftFocused?: boolean;
cellElement?: HTMLElement;
fromTableCell?: boolean;
};
export const FieldDisplay = ({ export const FieldDisplay = ({
isHovered, isCellSoftFocused,
reference, cellElement,
fromTableCell, fromTableCell,
}: FieldDisplayProps & { fromTableCell?: boolean }) => { }: FieldDisplayProps) => {
const { fieldDefinition, isLabelIdentifier } = useContext(FieldContext); const { fieldDefinition, isLabelIdentifier } = useContext(FieldContext);
const isChipDisplay = const isChipDisplay =
@ -75,7 +78,11 @@ export const FieldDisplay = ({
) : isFieldLink(fieldDefinition) ? ( ) : isFieldLink(fieldDefinition) ? (
<LinkFieldDisplay /> <LinkFieldDisplay />
) : isFieldLinks(fieldDefinition) ? ( ) : isFieldLinks(fieldDefinition) ? (
<LinksFieldDisplay /> <LinksFieldDisplay
isCellSoftFocused={isCellSoftFocused}
cellElement={cellElement}
fromTableCell={fromTableCell}
/>
) : isFieldCurrency(fieldDefinition) ? ( ) : isFieldCurrency(fieldDefinition) ? (
<CurrencyFieldDisplay /> <CurrencyFieldDisplay />
) : isFieldFullName(fieldDefinition) ? ( ) : isFieldFullName(fieldDefinition) ? (
@ -84,9 +91,9 @@ export const FieldDisplay = ({
<SelectFieldDisplay /> <SelectFieldDisplay />
) : isFieldMultiSelect(fieldDefinition) ? ( ) : isFieldMultiSelect(fieldDefinition) ? (
<MultiSelectFieldDisplay <MultiSelectFieldDisplay
isHovered={isHovered} isCellSoftFocused={isCellSoftFocused}
reference={reference} cellElement={cellElement}
withDropDownBorder={fromTableCell} fromTableCell={fromTableCell}
/> />
) : isFieldAddress(fieldDefinition) ? ( ) : isFieldAddress(fieldDefinition) ? (
<AddressFieldDisplay /> <AddressFieldDisplay />

View File

@ -1,8 +1,25 @@
import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField'; import { useLinksField } from '@/object-record/record-field/meta-types/hooks/useLinksField';
import { LinksDisplay } from '@/ui/field/display/components/LinksDisplay'; import { LinksDisplay } from '@/ui/field/display/components/LinksDisplay';
export const LinksFieldDisplay = () => { type LinksFieldDisplayProps = {
isCellSoftFocused?: boolean;
cellElement?: HTMLElement;
fromTableCell?: boolean;
};
export const LinksFieldDisplay = ({
isCellSoftFocused,
cellElement,
fromTableCell,
}: LinksFieldDisplayProps) => {
const { fieldValue } = useLinksField(); const { fieldValue } = useLinksField();
return <LinksDisplay value={fieldValue} />; return (
<LinksDisplay
value={fieldValue}
anchorElement={cellElement}
isChipCountDisplayed={isCellSoftFocused}
withExpandedListBorder={fromTableCell}
/>
);
}; };

View File

@ -1,15 +1,17 @@
import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField'; import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField';
import { Tag } from '@/ui/display/tag/components/Tag'; import { Tag } from '@/ui/display/tag/components/Tag';
import { import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
ExpandableList,
ExpandableListProps, type MultiSelectFieldDisplayProps = {
} from '@/ui/layout/expandable-list/components/ExpandableList'; isCellSoftFocused?: boolean;
cellElement?: HTMLElement;
fromTableCell?: boolean;
};
type MultiSelectFieldDisplayProps = ExpandableListProps;
export const MultiSelectFieldDisplay = ({ export const MultiSelectFieldDisplay = ({
isHovered, isCellSoftFocused,
reference, cellElement,
withDropDownBorder, fromTableCell,
}: MultiSelectFieldDisplayProps) => { }: MultiSelectFieldDisplayProps) => {
const { fieldValues, fieldDefinition } = useMultiSelectField(); const { fieldValues, fieldDefinition } = useMultiSelectField();
@ -19,11 +21,13 @@ export const MultiSelectFieldDisplay = ({
) )
: []; : [];
return selectedOptions ? ( if (!selectedOptions) return null;
return (
<ExpandableList <ExpandableList
isHovered={isHovered} anchorElement={cellElement}
reference={reference} isChipCountDisplayed={isCellSoftFocused}
withDropDownBorder={withDropDownBorder} withExpandedListBorder={fromTableCell}
> >
{selectedOptions.map((selectedOption, index) => ( {selectedOptions.map((selectedOption, index) => (
<Tag <Tag
@ -33,7 +37,5 @@ export const MultiSelectFieldDisplay = ({
/> />
))} ))}
</ExpandableList> </ExpandableList>
) : (
<></>
); );
}; };

View File

@ -99,7 +99,12 @@ export const RecordInlineCell = ({
isReadOnly={readonly} isReadOnly={readonly}
/> />
} }
displayModeContent={<FieldDisplay />} displayModeContent={({ cellElement, isCellSoftFocused }) => (
<FieldDisplay
cellElement={cellElement}
isCellSoftFocused={isCellSoftFocused}
/>
)}
isDisplayModeContentEmpty={isFieldEmpty} isDisplayModeContentEmpty={isFieldEmpty}
isDisplayModeFixHeight isDisplayModeFixHeight
editModeContentOnly={isFieldInputOnly} editModeContentOnly={isFieldInputOnly}

View File

@ -1,4 +1,4 @@
import React, { useContext, useEffect, useRef, useState } from 'react'; import React, { useContext, useState } from 'react';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
import { Tooltip } from 'react-tooltip'; import { Tooltip } from 'react-tooltip';
import { css, useTheme } from '@emotion/react'; import { css, useTheme } from '@emotion/react';
@ -7,7 +7,6 @@ import { IconComponent } from 'twenty-ui';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay'; import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay';
import { ExpandableListProps } from '@/ui/layout/expandable-list/components/ExpandableList';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useInlineCell } from '../hooks/useInlineCell'; import { useInlineCell } from '../hooks/useInlineCell';
@ -111,7 +110,13 @@ type RecordInlineCellContainerProps = {
buttonIcon?: IconComponent; buttonIcon?: IconComponent;
editModeContent?: React.ReactNode; editModeContent?: React.ReactNode;
editModeContentOnly?: boolean; editModeContentOnly?: boolean;
displayModeContent: React.ReactNode; displayModeContent: ({
isCellSoftFocused,
cellElement,
}: {
isCellSoftFocused: boolean;
cellElement?: HTMLDivElement;
}) => React.ReactNode;
customEditHotkeyScope?: HotkeyScope; customEditHotkeyScope?: HotkeyScope;
isDisplayModeContentEmpty?: boolean; isDisplayModeContentEmpty?: boolean;
isDisplayModeFixHeight?: boolean; isDisplayModeFixHeight?: boolean;
@ -136,24 +141,25 @@ export const RecordInlineCellContainer = ({
loading = false, loading = false,
}: RecordInlineCellContainerProps) => { }: RecordInlineCellContainerProps) => {
const { entityId, fieldDefinition } = useContext(FieldContext); const { entityId, fieldDefinition } = useContext(FieldContext);
const reference = useRef<HTMLDivElement>(null); // Used by some fields in ExpandableList as an anchor for the floating element.
// floating-ui mentions that `useState` must be used instead of `useRef`,
// see https://floating-ui.com/docs/useFloating#elements
const [cellElement, setCellElement] = useState<HTMLDivElement | null>(null);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const [isHoveredForDisplayMode, setIsHoveredForDisplayMode] = useState(false); const [isCellSoftFocused, setIsCellSoftFocused] = useState(false);
const [newDisplayModeContent, setNewDisplayModeContent] =
useState<React.ReactNode>(displayModeContent);
const handleContainerMouseEnter = () => { const handleContainerMouseEnter = () => {
if (!readonly) { if (!readonly) {
setIsHovered(true); setIsHovered(true);
} }
setIsHoveredForDisplayMode(true); setIsCellSoftFocused(true);
}; };
const handleContainerMouseLeave = () => { const handleContainerMouseLeave = () => {
if (!readonly) { if (!readonly) {
setIsHovered(false); setIsHovered(false);
} }
setIsHoveredForDisplayMode(false); setIsCellSoftFocused(false);
}; };
const { isInlineCellInEditMode, openInlineCell } = useInlineCell(); const { isInlineCellInEditMode, openInlineCell } = useInlineCell();
@ -174,17 +180,6 @@ export const RecordInlineCellContainer = ({
const theme = useTheme(); const theme = useTheme();
const labelId = `label-${entityId}-${fieldDefinition?.metadata?.fieldName}`; const labelId = `label-${entityId}-${fieldDefinition?.metadata?.fieldName}`;
useEffect(() => {
if (React.isValidElement<ExpandableListProps>(displayModeContent)) {
setNewDisplayModeContent(
React.cloneElement(displayModeContent, {
isHovered: isHoveredForDisplayMode,
reference: reference.current || undefined,
}),
);
}
}, [isHoveredForDisplayMode, displayModeContent, reference]);
const showContent = () => { const showContent = () => {
if (loading) { if (loading) {
return <StyledInlineCellSkeletonLoader />; return <StyledInlineCellSkeletonLoader />;
@ -215,7 +210,10 @@ export const RecordInlineCellContainer = ({
isHovered={isHovered} isHovered={isHovered}
emptyPlaceholder={showLabel ? 'Empty' : label} emptyPlaceholder={showLabel ? 'Empty' : label}
> >
{newDisplayModeContent} {displayModeContent({
isCellSoftFocused,
cellElement: cellElement ?? undefined,
})}
</RecordInlineCellDisplayMode> </RecordInlineCellDisplayMode>
{showEditButton && <RecordInlineCellButton Icon={buttonIcon} />} {showEditButton && <RecordInlineCellButton Icon={buttonIcon} />}
</StyledClickableContainer> </StyledClickableContainer>
@ -252,7 +250,7 @@ export const RecordInlineCellContainer = ({
)} )}
</StyledLabelAndIconContainer> </StyledLabelAndIconContainer>
)} )}
<StyledValueContainer ref={reference}> <StyledValueContainer ref={setCellElement}>
{showContent()} {showContent()}
</StyledValueContainer> </StyledValueContainer>
</StyledInlineCellBaseContainer> </StyledInlineCellBaseContainer>

View File

@ -102,7 +102,13 @@ export const RecordTableCell = ({
isReadOnly={isReadOnly} isReadOnly={isReadOnly}
/> />
} }
nonEditModeContent={<FieldDisplay fromTableCell />} nonEditModeContent={({ isCellSoftFocused, cellElement }) => (
<FieldDisplay
isCellSoftFocused={isCellSoftFocused}
cellElement={cellElement}
fromTableCell
/>
)}
/> />
); );
}; };

View File

@ -1,10 +1,4 @@
import React, { import React, { ReactElement, useContext, useState } from 'react';
ReactElement,
useContext,
useEffect,
useRef,
useState,
} from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { IconArrowUpRight } from 'twenty-ui'; import { IconArrowUpRight } from 'twenty-ui';
@ -20,7 +14,6 @@ import { useOpenRecordTableCellFromCell } from '@/object-record/record-table/rec
import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext'; import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext';
import { isSoftFocusOnTableCellComponentFamilyState } from '@/object-record/record-table/states/isSoftFocusOnTableCellComponentFamilyState'; import { isSoftFocusOnTableCellComponentFamilyState } from '@/object-record/record-table/states/isSoftFocusOnTableCellComponentFamilyState';
import { isTableCellInEditModeComponentFamilyState } from '@/object-record/record-table/states/isTableCellInEditModeComponentFamilyState'; import { isTableCellInEditModeComponentFamilyState } from '@/object-record/record-table/states/isTableCellInEditModeComponentFamilyState';
import { ExpandableListProps } from '@/ui/layout/expandable-list/components/ExpandableList';
import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope';
import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId';
import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId'; import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId';
@ -58,7 +51,13 @@ const StyledCellBaseContainer = styled.div<{ softFocus: boolean }>`
export type RecordTableCellContainerProps = { export type RecordTableCellContainerProps = {
editModeContent: ReactElement; editModeContent: ReactElement;
nonEditModeContent: ReactElement; nonEditModeContent?: ({
isCellSoftFocused,
cellElement,
}: {
isCellSoftFocused: boolean;
cellElement?: HTMLTableCellElement;
}) => ReactElement;
editHotkeyScope?: HotkeyScope; editHotkeyScope?: HotkeyScope;
transparent?: boolean; transparent?: boolean;
maxContentWidth?: number; maxContentWidth?: number;
@ -76,10 +75,14 @@ export const RecordTableCellContainer = ({
editHotkeyScope, editHotkeyScope,
}: RecordTableCellContainerProps) => { }: RecordTableCellContainerProps) => {
const { columnIndex } = useContext(RecordTableCellContext); const { columnIndex } = useContext(RecordTableCellContext);
const reference = useRef<HTMLTableCellElement>(null); // Used by some fields in ExpandableList as an anchor for the floating element.
const [isHovered, setIsHovered] = useState(false); // floating-ui mentions that `useState` must be used instead of `useRef`,
const [newNonEditModeContent, setNewNonEditModeContent] = // see https://floating-ui.com/docs/useFloating#elements
useState<ReactElement>(nonEditModeContent); const [cellElement, setCellElement] = useState<HTMLTableCellElement | null>(
null,
);
const [isCellBaseContainerHovered, setIsCellBaseContainerHovered] =
useState(false);
const { isReadOnly, isSelected, recordId } = useContext( const { isReadOnly, isSelected, recordId } = useContext(
RecordTableRowContext, RecordTableRowContext,
); );
@ -127,13 +130,13 @@ export const RecordTableCellContainer = ({
const handleContainerMouseEnter = () => { const handleContainerMouseEnter = () => {
onCellMouseEnter({ onCellMouseEnter({
cellPosition, cellPosition,
isHovered, isHovered: isCellBaseContainerHovered,
setIsHovered, setIsHovered: setIsCellBaseContainerHovered,
}); });
}; };
const handleContainerMouseLeave = () => { const handleContainerMouseLeave = () => {
setIsHovered(false); setIsCellBaseContainerHovered(false);
}; };
const editModeContentOnly = useIsFieldInputOnly(); const editModeContentOnly = useIsFieldInputOnly();
@ -150,20 +153,9 @@ export const RecordTableCellContainer = ({
(!isFirstColumn || !isEmpty) && (!isFirstColumn || !isEmpty) &&
!isReadOnly; !isReadOnly;
useEffect(() => {
if (React.isValidElement<ExpandableListProps>(nonEditModeContent)) {
setNewNonEditModeContent(
React.cloneElement(nonEditModeContent, {
isHovered: showButton,
reference: reference.current || undefined,
}),
);
}
}, [nonEditModeContent, showButton, reference]);
return ( return (
<StyledTd <StyledTd
ref={reference} ref={setCellElement}
isSelected={isSelected} isSelected={isSelected}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
isInEditMode={isCurrentTableCellInEditMode} isInEditMode={isCurrentTableCellInEditMode}
@ -181,7 +173,12 @@ export const RecordTableCellContainer = ({
) : hasSoftFocus ? ( ) : hasSoftFocus ? (
<> <>
<RecordTableCellSoftFocusMode> <RecordTableCellSoftFocusMode>
{editModeContentOnly ? editModeContent : newNonEditModeContent} {editModeContentOnly
? editModeContent
: nonEditModeContent?.({
isCellSoftFocused: true,
cellElement: cellElement ?? undefined,
})}
</RecordTableCellSoftFocusMode> </RecordTableCellSoftFocusMode>
{showButton && ( {showButton && (
<RecordTableCellButton <RecordTableCellButton
@ -196,7 +193,10 @@ export const RecordTableCellContainer = ({
<RecordTableCellDisplayMode> <RecordTableCellDisplayMode>
{editModeContentOnly {editModeContentOnly
? editModeContent ? editModeContent
: newNonEditModeContent} : nonEditModeContent?.({
isCellSoftFocused: false,
cellElement: cellElement ?? undefined,
})}
</RecordTableCellDisplayMode> </RecordTableCellDisplayMode>
)} )}
{showButton && ( {showButton && (

View File

@ -1,6 +1,6 @@
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { IconComponent } from 'twenty-ui'; import { IconComponent, OverflowingTextWithTooltip } from 'twenty-ui';
import { ThemeColor } from '@/ui/theme/constants/MainColorNames'; import { ThemeColor } from '@/ui/theme/constants/MainColorNames';
import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema'; 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} /> <Icon size={theme.icon.size.sm} stroke={theme.icon.stroke.sm} />
</StyledIconContainer> </StyledIconContainer>
)} )}
<StyledContent>{text}</StyledContent> <StyledContent>
<OverflowingTextWithTooltip text={text} />
</StyledContent>
</StyledTag> </StyledTag>
); );
}; };

View File

@ -1,8 +1,10 @@
import { MouseEventHandler, useMemo } from 'react'; import { MouseEventHandler, useMemo } from 'react';
import styled from '@emotion/styled';
import { FieldLinksValue } from '@/object-record/record-field/types/FieldMetadata'; 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 { RoundedLink } from '@/ui/navigation/link/components/RoundedLink';
import { import {
LinkType, LinkType,
@ -13,16 +15,19 @@ import { isDefined } from '~/utils/isDefined';
import { getAbsoluteUrl } from '~/utils/url/getAbsoluteUrl'; import { getAbsoluteUrl } from '~/utils/url/getAbsoluteUrl';
import { getUrlHostName } from '~/utils/url/getUrlHostName'; import { getUrlHostName } from '~/utils/url/getUrlHostName';
const StyledContainer = styled(EllipsisDisplay)` type LinksDisplayProps = Pick<
display: flex; ExpandableListProps,
gap: ${({ theme }) => theme.spacing(1)}; 'anchorElement' | 'isChipCountDisplayed' | 'withExpandedListBorder'
`; > & {
type LinksDisplayProps = {
value?: FieldLinksValue; value?: FieldLinksValue;
}; };
export const LinksDisplay = ({ value }: LinksDisplayProps) => { export const LinksDisplay = ({
anchorElement,
isChipCountDisplayed,
withExpandedListBorder,
value,
}: LinksDisplayProps) => {
const links = useMemo( const links = useMemo(
() => () =>
[ [
@ -49,7 +54,11 @@ export const LinksDisplay = ({ value }: LinksDisplayProps) => {
const handleClick: MouseEventHandler = (event) => event.stopPropagation(); const handleClick: MouseEventHandler = (event) => event.stopPropagation();
return ( return (
<StyledContainer> <ExpandableList
anchorElement={anchorElement}
isChipCountDisplayed={isChipCountDisplayed}
withExpandedListBorder={withExpandedListBorder}
>
{links.map(({ url, label, type }, index) => {links.map(({ url, label, type }, index) =>
type === LinkType.LinkedIn || type === LinkType.Twitter ? ( type === LinkType.LinkedIn || type === LinkType.Twitter ? (
<SocialLink key={index} href={url} onClick={handleClick} type={type}> <SocialLink key={index} href={url} onClick={handleClick} type={type}>
@ -61,6 +70,6 @@ export const LinksDisplay = ({ value }: LinksDisplayProps) => {
</RoundedLink> </RoundedLink>
), ),
)} )}
</StyledContainer> </ExpandableList>
); );
}; };

View File

@ -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>
);
};

View File

@ -1,48 +1,49 @@
import { ReactElement, useEffect, useState } from 'react'; import { ReactElement, useCallback, useEffect, useRef, useState } from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { offset, useFloating } from '@floating-ui/react';
import { Chip, ChipVariant } from 'twenty-ui'; import { Chip, ChipVariant } from 'twenty-ui';
import { AnimatedContainer } from '@/object-record/record-table/components/AnimatedContainer'; import { AnimatedContainer } from '@/object-record/record-table/components/AnimatedContainer';
import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { ExpandedListDropdown } from '@/ui/layout/expandable-list/components/ExpandedListDropdown';
import { ChildrenContainer } from '@/ui/layout/expandable-list/components/ChildrenContainer'; import { isFirstOverflowingChildElement } from '@/ui/layout/expandable-list/utils/isFirstOverflowingChildElement';
import { getChildrenProperties } from '@/ui/layout/expandable-list/utils/getChildProperties'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { getChipContentWidth } from '@/ui/layout/expandable-list/utils/getChipContentWidth'; import { isDefined } from '~/utils/isDefined';
export const GAP_WIDTH = 4;
const StyledContainer = styled.div` const StyledContainer = styled.div`
align-items: center; align-items: center;
display: flex; display: flex;
gap: ${({ theme }) => theme.spacing(1)}; gap: ${({ theme }) => theme.spacing(1)};
justify-content: space-between; justify-content: space-between;
min-width: 100%;
width: 100%; width: 100%;
`; `;
const StyledRelationsListContainer = styled.div<{ const StyledChildrenContainer = 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; display: flex;
flex-wrap: wrap;
gap: ${({ theme }) => theme.spacing(1)}; gap: ${({ theme }) => theme.spacing(1)};
padding: ${({ theme }) => theme.spacing(2)}; overflow: hidden;
outline: ${(props) => max-width: 100%;
props.withDropDownBorder flex: 0 1 fit-content;
? `1px solid ${props.theme.font.color.extraLight}` position: relative; // Needed so children elements compute their offsetLeft relatively to this element.
: 'none'}; `;
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 = { export type ExpandableListProps = {
isHovered?: boolean; anchorElement?: HTMLElement;
reference?: HTMLDivElement; isChipCountDisplayed?: boolean;
forceDisplayHiddenCount?: boolean; withExpandedListBorder?: boolean;
withDropDownBorder?: boolean;
}; };
export type ChildrenProperty = { export type ChildrenProperty = {
@ -52,95 +53,127 @@ export type ChildrenProperty = {
export const ExpandableList = ({ export const ExpandableList = ({
children, children,
isHovered, anchorElement,
reference, isChipCountDisplayed: isChipCountDisplayedFromProps,
forceDisplayHiddenCount = false, withExpandedListBorder = false,
withDropDownBorder = false,
}: { }: {
children: ReactElement[]; children: ReactElement[];
} & ExpandableListProps) => { } & ExpandableListProps) => {
const [containerWidth, setContainerWidth] = useState(0); // isChipCountDisplayedInternal => uncontrolled display of the chip count.
const [isDropdownMenuOpen, setIsDropdownMenuOpen] = useState(false); // isChipCountDisplayedFromProps => controlled display of the chip count.
const [childrenWidths, setChildrenWidths] = useState<Record<number, number>>( // 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 const hiddenChildrenCount = children.length - firstHiddenChildIndex;
// To avoid it, we set the Chip width and make sure it can display its content (a number greater than 1) const canDisplayChipCount = isChipCountDisplayed && hiddenChildrenCount > 0;
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( const handleChipCountClick = useCallback((event: React.MouseEvent) => {
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(); 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(() => { useEffect(() => {
if (!isHovered) { resetFirstHiddenChildIndex();
setIsDropdownMenuOpen(false); }, [isChipCountDisplayed, children.length, resetFirstHiddenChildIndex]);
}
}, [isHovered]); useListenClickOutside({
refs: [containerRef],
callback: () => {
// Handle container resize
if (
childrenContainerElement?.clientWidth !== previousChildrenContainerWidth
) {
resetFirstHiddenChildIndex();
setPreviousChildrenContainerWidth(
childrenContainerElement?.clientWidth ?? 0,
);
}
},
});
return ( return (
<StyledContainer <StyledContainer
ref={(el) => { ref={containerRef}
if (!el) return; onMouseEnter={
setContainerWidth(el.getBoundingClientRect().width); isChipCountDisplayedFromProps
}} ? undefined
: () => setIsChipCountDisplayedInternal(true)
}
onMouseLeave={
isChipCountDisplayedFromProps
? undefined
: () => setIsChipCountDisplayedInternal(false)
}
> >
<ChildrenContainer <StyledChildrenContainer ref={setChildrenContainerElement}>
childrenProperties={childrenProperties} {children.slice(0, firstHiddenChildIndex).map((child, index) => (
setChildrenWidths={setChildrenWidths} <StyledChildContainer
isFocusedMode={isFocusedMode} key={index}
> ref={(childElement) => {
{children} if (
</ChildrenContainer> // First element is always displayed.
{displayHiddenCountChip && ( index > 0 &&
isFirstOverflowingChildElement({
containerElement: childrenContainerElement,
childElement,
})
) {
setFirstHiddenChildIndex(index);
}
}}
>
{child}
</StyledChildContainer>
))}
</StyledChildrenContainer>
{canDisplayChipCount && (
<AnimatedContainer> <AnimatedContainer>
<Chip <StyledChipCount
label={`+${hiddenChildrenCount}`} label={`+${hiddenChildrenCount}`}
variant={ChipVariant.Highlighted} variant={ChipVariant.Highlighted}
onClick={openDropdownMenu} onClick={handleChipCountClick}
/> />
</AnimatedContainer> </AnimatedContainer>
)} )}
{isDropdownMenuOpen && ( {isListExpanded && (
<DropdownMenu <ExpandedListDropdown
ref={refs.setFloating} anchorElement={anchorElement ?? childrenContainerElement ?? undefined}
style={floatingStyles} onClickOutside={() => {
width={ resetFirstHiddenChildIndex();
reference setIsListExpanded(false);
? Math.max(220, reference.getBoundingClientRect().width) }}
: undefined withBorder={withExpandedListBorder}
}
> >
<StyledRelationsListContainer withDropDownBorder={withDropDownBorder}> {children}
{children} </ExpandedListDropdown>
</StyledRelationsListContainer>
</DropdownMenu>
)} )}
</StyledContainer> </StyledContainer>
); );

View File

@ -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>
);
};

View File

@ -1,13 +1,11 @@
import { ReactElement, useRef, useState } from 'react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { expect } from '@storybook/jest';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { userEvent, within } from '@storybook/test';
import { ComponentDecorator } from 'packages/twenty-ui'; import { ComponentDecorator } from 'packages/twenty-ui';
import { Tag } from '@/ui/display/tag/components/Tag'; import { Tag } from '@/ui/display/tag/components/Tag';
import { import { ExpandableList } from '@/ui/layout/expandable-list/components/ExpandableList';
ExpandableList,
ExpandableListProps,
} from '@/ui/layout/expandable-list/components/ExpandableList';
import { MAIN_COLOR_NAMES } from '@/ui/theme/constants/MainColorNames'; import { MAIN_COLOR_NAMES } from '@/ui/theme/constants/MainColorNames';
const StyledContainer = styled.div` const StyledContainer = styled.div`
@ -15,60 +13,55 @@ const StyledContainer = styled.div`
width: 300px; 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> = { const meta: Meta<typeof ExpandableList> = {
title: 'UI/Layout/ExpandableList/ExpandableList', title: 'UI/Layout/ExpandableList/ExpandableList',
component: ExpandableList, component: ExpandableList,
decorators: [ComponentDecorator], decorators: [
(Story) => (
<StyledContainer>
<Story />
</StyledContainer>
),
ComponentDecorator,
],
args: { args: {
children: [ children: Array.from({ length: 7 }, (_, index) => (
<Tag key={1} text={'Option 1'} color={MAIN_COLOR_NAMES[0]} />, <Tag
<Tag key={2} text={'Option 2'} color={MAIN_COLOR_NAMES[1]} />, key={index}
<Tag key={3} text={'Option 3'} color={MAIN_COLOR_NAMES[2]} />, text={`Option ${index + 1}`}
<Tag key={4} text={'Option 4'} color={MAIN_COLOR_NAMES[3]} />, color={MAIN_COLOR_NAMES[index]}
<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]} />, isChipCountDisplayed: false,
],
isHovered: undefined,
reference: undefined,
forceDisplayHiddenCount: false,
withDropDownBorder: false,
}, },
argTypes: { argTypes: {
children: { control: false }, children: { control: false },
isHovered: { control: false }, anchorElement: { control: false },
reference: { control: false },
}, },
render: Render,
}; };
export default meta; export default meta;
type Story = StoryObj<typeof ExpandableList>; type Story = StoryObj<typeof ExpandableList>;
export const Default: Story = {}; 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 },
};

View File

@ -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 },
});
});
});

View File

@ -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);
});
});

View File

@ -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;
};

View File

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

View File

@ -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;

View File

@ -10,22 +10,15 @@ type RoundedLinkProps = {
onClick?: (event: React.MouseEvent<HTMLElement>) => void; onClick?: (event: React.MouseEvent<HTMLElement>) => void;
}; };
const StyledClickable = styled.div` const StyledLink = styled(ReactLink)`
overflow: hidden; max-width: 100%;
white-space: nowrap;
a {
color: inherit;
overflow: hidden;
text-decoration: none;
text-overflow: ellipsis;
}
`; `;
const StyledChip = styled(Chip)` const StyledChip = styled(Chip)`
border-color: ${({ theme }) => theme.border.color.strong}; border-color: ${({ theme }) => theme.border.color.strong};
box-sizing: border-box; box-sizing: border-box;
padding: ${({ theme }) => theme.spacing(2)}; padding: ${({ theme }) => theme.spacing(2)};
max-width: 100%;
`; `;
export const RoundedLink = ({ export const RoundedLink = ({
@ -33,20 +26,21 @@ export const RoundedLink = ({
className, className,
href, href,
onClick, onClick,
}: RoundedLinkProps) => ( }: RoundedLinkProps) => {
<div> if (!children) return null;
{children !== '' ? (
<StyledClickable className={className}> return (
<ReactLink target="_blank" to={href} onClick={onClick}> <StyledLink
<StyledChip className={className}
label={`${children}`} target="_blank"
variant={ChipVariant.Rounded} to={href}
size={ChipSize.Small} onClick={onClick}
/> >
</ReactLink> <StyledChip
</StyledClickable> label={`${children}`}
) : ( variant={ChipVariant.Rounded}
<></> size={ChipSize.Small}
)} />
</div> </StyledLink>
); );
};

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'; import { useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
@ -29,22 +29,13 @@ export const OverflowingTextWithTooltip = ({
mutliline?: boolean; mutliline?: boolean;
}) => { }) => {
const textElementId = `title-id-${uuidV4()}`; const textElementId = `title-id-${uuidV4()}`;
const [textElement, setTextElement] = useState<HTMLDivElement | null>(null);
const textRef = useRef<HTMLDivElement>(null); const isTitleOverflowing =
(text?.length ?? 0) > 0 &&
const [isTitleOverflowing, setIsTitleOverflowing] = useState(false); !!textElement &&
(textElement.scrollHeight > textElement.clientHeight ||
useEffect(() => { textElement.scrollWidth > textElement.clientWidth);
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>) => { const handleTooltipClick = (event: React.MouseEvent<HTMLDivElement>) => {
event.stopPropagation(); event.stopPropagation();
@ -56,7 +47,7 @@ export const OverflowingTextWithTooltip = ({
<StyledOverflowingText <StyledOverflowingText
data-testid="tooltip" data-testid="tooltip"
className={className} className={className}
ref={textRef} ref={setTextElement}
id={textElementId} id={textElementId}
cursorPointer={isTitleOverflowing} cursorPointer={isTitleOverflowing}
> >