Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -139,4 +139,6 @@ dist
.direnv

.idea
.DS_Store
.DS_Store

.cursorrules
31 changes: 31 additions & 0 deletions libs/react/ui/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,37 @@
/* Alpha */
--alpha-250: var(--color-alpha-black-10);
--alpha-400: var(--color-alpha-black-24);

/* Shadow */
--shadow-border-base:
0 -1px 0 0 var(--color-alpha-white-6), 0 0 0 1px var(--color-alpha-white-6), 0 0 0 1px
var(--color-neutral-800), 0 0 1px 1.5px var(--color-alpha-black-24), 0 2px 2px 0
var(--color-alpha-black-24);
--shadow-border-interactive-with-active: 0 0 0 1px #ff8b16, 0 0 0 4px rgba(255, 49, 0, 0.25);
--shadow-border-error: 0 0 0 1px #ff1f5a, 0 0 0 3px rgba(246, 0, 67, 0.25);
--shadow-button-inverted:
0 -1px 0 0 var(--color-alpha-white-12), 0 0 0 1px var(--color-alpha-white-10), 0 0 0 1px
var(--color-neutral-600), 0 0 1px 1.5px var(--color-alpha-black-24), 0 2px 2px 0
var(--color-alpha-black-24);
--shadow-button-inverted-focus:
0 -1px 0 0 var(--color-alpha-white-12), 0 0 0 1px var(--color-alpha-white-12), 0 0 0 1px
var(--color-neutral-600), 0 0 0 2px var(--color-alpha-white-88), 0 0 0 4px
var(--color-primary-500);
--shadow-button-neutral:
0 -1px 0 0 var(--color-alpha-white-6), 0 0 0 1px var(--color-alpha-white-6), 0 0 0 1px
var(--color-neutral-800), 0 0 1px 1.5px var(--color-alpha-black-24), 0 2px 2px 0
var(--color-alpha-black-24);
--shadow-button-neutral-focus:
0 -1px 0 0 var(--color-alpha-white-6), 0 0 0 1px var(--color-alpha-white-6), 0 0 0 1px
var(--color-neutral-800), 0 0 0 2px var(--color-alpha-white-88), 0 0 0 4px
var(--color-primary-500);
--shadow-button-danger:
0 -1px 0 0 var(--color-alpha-white-16), 0 0 0 1px var(--color-alpha-white-12), 0 0 0 1px
var(--color-red-700), 0 0 1px 1.5px var(--color-alpha-black-24), 0 2px 2px 0
var(--color-alpha-black-24);
--shadow-button-danger-focus:
0 -1px 0 0 var(--color-alpha-white-16), 0 0 0 1px var(--color-alpha-white-12), 0 0 0 1px
var(--color-red-700), 0 0 0 2px var(--color-alpha-white-88), 0 0 0 4px var(--color-primary-500);
}

.dark {
Expand Down
186 changes: 186 additions & 0 deletions libs/react/ui/src/components/avatar/avatar-group.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
import {cva, type VariantProps} from 'class-variance-authority';
import {
Children,
type ComponentProps,
cloneElement,
type ReactElement,
type ReactNode,
useMemo,
} from 'react';
import {cn} from 'utils/cn';
import {TooltipContent, TooltipProvider, TooltipTrigger} from '../tooltip/tooltip';

const avatarGroupVariants = cva('flex items-start', {
variants: {
size: {
'3xs': '-space-x-4',
'2xs': '-space-x-4',
xs: '-space-x-4',
sm: '-space-x-4',
md: '-space-x-4',
lg: '-space-x-6',
xl: '-space-x-6',
'2xl': '-space-x-12',
'3xl': '-space-x-12',
},
},
defaultVariants: {
size: 'md',
},
});

const avatarGroupOverflowVariants = cva(
'flex shrink-0 items-center justify-center rounded-full bg-background-components-base text-foreground-neutral-subtle font-medium ring-1 ring-border-neutral-base-component ring-offset-1 ring-offset-background-neutral-base shadow-button-neutral',
{
variants: {
size: {
'3xs': 'size-[18px] text-[10px] leading-[10px]',
'2xs': 'size-[20px] text-[11px] leading-[11px]',
xs: 'size-[24px] text-xs leading-4',
sm: 'size-[28px] text-xs leading-5',
md: 'size-[32px] text-sm leading-5',
lg: 'size-[36px] text-sm leading-5',
xl: 'size-[40px] text-base leading-6',
'2xl': 'size-[80px] text-2xl leading-8',
'3xl': 'size-[120px] text-4xl leading-[56px]',
},
},
defaultVariants: {
size: 'md',
},
},
);

type TooltipContentProps = ComponentProps<typeof TooltipContent>;

type AvatarContainerProps = {
children: ReactNode;
zIndex: number;
tooltipContent?: ReactNode;
tooltipProps?: Partial<TooltipContentProps>;
animateOnHover?: boolean;
};

function AvatarContainer({
children,
zIndex,
tooltipContent,
tooltipProps,
animateOnHover = false,
}: AvatarContainerProps) {
return (
<TooltipPrimitive.Root>
<TooltipTrigger asChild>
<div
data-slot="avatar-container"
className={cn(
'relative',
animateOnHover && 'transition-transform duration-300 ease-out hover:-translate-y-2',
)}
style={{zIndex}}
>
{children}
</div>
</TooltipTrigger>
{tooltipContent && (
<AvatarGroupTooltip {...tooltipProps}>{tooltipContent}</AvatarGroupTooltip>
)}
</TooltipPrimitive.Root>
);
}

function getTooltipContent(children: ReactNode): ReactNode | null {
const tooltip = Children.toArray(children).find(
(child) =>
typeof child === 'object' &&
child !== null &&
'type' in child &&
child.type === AvatarGroupTooltip,
) as ReactElement<ComponentProps<typeof AvatarGroupTooltip>> | undefined;

return tooltip?.props.children || null;
}

type AvatarGroupTooltipProps = TooltipContentProps;

function AvatarGroupTooltip(props: AvatarGroupTooltipProps) {
return <TooltipContent {...props} />;
}

type AvatarGroupProps = ComponentProps<'div'> &
VariantProps<typeof avatarGroupVariants> & {
children: ReactElement[];
maxVisible?: number;
animateOnHover?: boolean;
tooltipProps?: Partial<TooltipContentProps>;
};

export function AvatarGroup({
className,
size = 'md',
children,
maxVisible,
animateOnHover = false,
tooltipProps = {side: 'top', sideOffset: 8},
...props
}: AvatarGroupProps) {
const normalizedSize = size ?? 'md';

const childrenArray = Children.toArray(children) as ReactElement[];

const {visibleCount, visibleAvatars, overflowCount} = useMemo(() => {
const count =
maxVisible !== undefined ? Math.min(maxVisible, childrenArray.length) : childrenArray.length;
return {
visibleCount: count,
visibleAvatars: childrenArray.slice(0, count),
overflowCount: childrenArray.length - count,
};
}, [childrenArray, maxVisible]);

return (
<TooltipProvider delayDuration={0}>
<div
className={cn(avatarGroupVariants({size: normalizedSize}), className)}
data-slot="avatar-group"
{...props}
>
{visibleAvatars.map((child, index) => {
const zIndex = index + 1;
const childProps = 'props' in child ? (child.props as {children?: ReactNode}) : {};
const tooltipContent = getTooltipContent(childProps.children);

return (
<AvatarContainer
key={child.key || index}
zIndex={zIndex}
tooltipContent={tooltipContent}
tooltipProps={tooltipProps}
animateOnHover={animateOnHover}
>
{cloneElement(child, {
...childProps,
children: tooltipContent ? undefined : childProps.children,
} as Partial<typeof childProps>)}
</AvatarContainer>
);
})}
{overflowCount > 0 && (
<div
className={cn(
'relative',
avatarGroupOverflowVariants({size: normalizedSize}),
'rounded-full',
)}
style={{zIndex: visibleCount + 1}}
>
+{overflowCount}
</div>
)}
</div>
</TooltipProvider>
);
}

export {AvatarGroupTooltip, type AvatarGroupTooltipProps};
Loading
Loading