feat: add LinkIconWithLinkOverlay component for enhanced link representation in navigation menu

Introduced the LinkIconWithLinkOverlay component to display a link icon with an optional favicon overlay in the navigation menu. This component utilizes the getLinkFaviconUrl utility to fetch favicons based on provided links and integrates with the NavigationMenuItemIcon to enhance link item rendering. Additionally, updated NavigationMenuItemIcon to utilize the new component for link items, improving visual consistency and user experience.
This commit is contained in:
Abdul Rahman
2026-03-05 17:34:34 +05:30
parent e208bf7c7a
commit ea9acdaa2e
4 changed files with 150 additions and 5 deletions
@@ -0,0 +1,115 @@
import { styled } from '@linaria/react';
import { useContext, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import type { IconComponent } from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme';
import { DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK } from '@/navigation-menu-item/constants/NavigationMenuItemDefaultColorLink';
import { getNavigationMenuItemIconStyleFromColor } from '@/navigation-menu-item/utils/get-navigation-menu-item-icon-style-from-color';
import { getLinkFaviconUrl } from '@/navigation-menu-item/utils/getLinkFaviconUrl';
const StyledCompositeContainer = styled.div`
align-items: center;
border-radius: 4px;
box-sizing: border-box;
display: flex;
flex-shrink: 0;
height: 16px;
justify-content: center;
position: relative;
width: 16px;
`;
const StyledMainIconWrapper = styled.div<{
$backgroundColor: string;
$borderColor?: string;
$noBackgroundOrBorder?: boolean;
}>`
position: absolute;
inset: 0;
border-radius: 4px;
box-sizing: border-box;
background-color: ${({ $backgroundColor, $noBackgroundOrBorder }) =>
$noBackgroundOrBorder ? 'transparent' : $backgroundColor};
display: flex;
align-items: center;
justify-content: center;
border: ${({ $borderColor, $noBackgroundOrBorder }) =>
$noBackgroundOrBorder || !$borderColor
? 'none'
: `1px solid ${$borderColor}`};
overflow: hidden;
`;
const StyledFaviconImage = styled.img`
height: 100%;
object-fit: contain;
width: 100%;
`;
const StyledLinkOverlay = styled.div<{ $backgroundColor: string }>`
align-items: center;
background-color: ${({ $backgroundColor }) => $backgroundColor};
border-radius: 4px;
bottom: -7px;
display: flex;
height: 14px;
justify-content: center;
position: absolute;
right: -7px;
width: 14px;
`;
export type LinkIconWithLinkOverlayProps = {
link: string | null | undefined;
LinkIcon: IconComponent;
DefaultIcon: IconComponent;
color?: string | null;
};
export const LinkIconWithLinkOverlay = ({
link,
LinkIcon,
DefaultIcon,
color: navItemColor,
}: LinkIconWithLinkOverlayProps) => {
const { theme } = useContext(ThemeContext);
const [faviconFailed, setFaviconFailed] = useState(false);
const faviconUrl = getLinkFaviconUrl(link);
const showFavicon = isDefined(faviconUrl) && !faviconFailed;
const linkStyle = getNavigationMenuItemIconStyleFromColor(
theme,
navItemColor ?? DEFAULT_NAVIGATION_MENU_ITEM_COLOR_LINK,
);
return (
<StyledCompositeContainer>
<StyledMainIconWrapper
$backgroundColor={linkStyle.backgroundColor}
$borderColor={linkStyle.borderColor}
$noBackgroundOrBorder={showFavicon}
>
{showFavicon ? (
<StyledFaviconImage
src={faviconUrl}
alt=""
onError={() => setFaviconFailed(true)}
/>
) : (
<DefaultIcon
size="14px"
stroke={theme.icon.stroke.md}
color={linkStyle.iconColor}
/>
)}
</StyledMainIconWrapper>
<StyledLinkOverlay $backgroundColor={theme.grayScale.gray4}>
<LinkIcon
size="12px"
stroke={theme.icon.stroke.md}
color={theme.grayScale.gray10}
/>
</StyledLinkOverlay>
</StyledCompositeContainer>
);
};
@@ -1,10 +1,11 @@
import { isNonEmptyString } from '@sniptt/guards';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { Avatar, useIcons } from 'twenty-ui/display';
import { Avatar, IconLink, IconWorld, useIcons } from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { LinkIconWithLinkOverlay } from '@/navigation-menu-item/components/LinkIconWithLinkOverlay';
import { StyledNavigationMenuItemIconContainer } from '@/navigation-menu-item/components/NavigationMenuItemIconContainer';
import { ObjectIconWithViewOverlay } from '@/navigation-menu-item/components/ObjectIconWithViewOverlay';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
@@ -65,6 +66,17 @@ export const NavigationMenuItemIcon = ({
);
}
if (navigationMenuItem.itemType === NavigationMenuItemType.LINK) {
return (
<LinkIconWithLinkOverlay
link={navigationMenuItem.link}
LinkIcon={IconLink}
DefaultIcon={IconWorld}
color={getEffectiveNavigationMenuItemColor(navigationMenuItem)}
/>
);
}
const iconToUse =
StandardIcon ??
(navigationMenuItem.Icon ? getIcon(navigationMenuItem.Icon) : undefined);
@@ -0,0 +1,20 @@
import { getLogoUrlFromDomainName } from 'twenty-shared/utils';
export const getLinkFaviconUrl = (
link: string | null | undefined,
): string | undefined => {
const trimmed = (link ?? '').trim();
if (!trimmed) {
return undefined;
}
const normalized =
trimmed.startsWith('http://') || trimmed.startsWith('https://')
? trimmed
: `https://${trimmed}`;
try {
const hostname = new URL(normalized).hostname;
return getLogoUrlFromDomainName(hostname);
} catch {
return undefined;
}
};
@@ -1,8 +1,7 @@
import { lazy, Suspense } from 'react';
import { IconLink } from 'twenty-ui/display';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { getEffectiveNavigationMenuItemColor } from '@/navigation-menu-item/utils/getEffectiveNavigationMenuItemColor';
import { getObjectMetadataForNavigationMenuItem } from '@/navigation-menu-item/utils/getObjectMetadataForNavigationMenuItem';
import type { ProcessedNavigationMenuItem } from '@/navigation-menu-item/utils/sortNavigationMenuItems';
import { NavigationDrawerItemForObjectMetadataItem } from '@/object-metadata/components/NavigationDrawerItemForObjectMetadataItem';
@@ -101,8 +100,7 @@ export const WorkspaceSectionItemContent = ({
onClick={
isNavigationMenuInEditMode ? editModeProps.onEditModeClick : undefined
}
Icon={IconLink}
iconColor={getEffectiveNavigationMenuItemColor(linkItem) ?? undefined}
Icon={() => <NavigationMenuItemIcon navigationMenuItem={linkItem} />}
active={false}
isSelectedInEditMode={editModeProps.isSelectedInEditMode}
isDragging={isDragging}