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
19 changes: 19 additions & 0 deletions apps/www/src/content/docs/components/avatar/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,22 @@ export const generatedColorDemo = {
<Avatar size={6} color={getAvatarColor("abcde")} fallback="RC" />
</Flex>`
};

export const groupDemo = {
type: 'code',
code: `
<Flex gap={5} direction="column">
<AvatarGroup max={3}>
<Avatar size={3} fallback="RC" />
<Avatar size={3} fallback="AB" color="mint" />
<Avatar size={3} fallback="JD" color="orange" />
<Avatar size={3} fallback="KL" color="sky" />
</AvatarGroup>
<AvatarGroup max={3}>
<Avatar size={7} fallback="RC" />
<Avatar size={7} fallback="AB" color="mint" />
<Avatar size={7} fallback="JD" color="orange" />
<Avatar size={7} fallback="KL" color="sky" />
</AvatarGroup>
</Flex>`
};
9 changes: 8 additions & 1 deletion apps/www/src/content/docs/components/avatar/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import {
colorsDemo,
radiusDemo,
imageDemo,
generatedColorDemo
generatedColorDemo,
groupDemo
} from "./demo.ts";

<Demo data={playground} />
Expand Down Expand Up @@ -71,6 +72,12 @@ use `getAvatarColor` utility to generate colors based on a string.

<Demo data={generatedColorDemo} />

### Group

`AvatarGroup` overlaps its avatars. Each avatar size sets its own overlap. With `max`, it shows a count avatar for the rest. The count avatar takes its size, radius, and variant from the first avatar, even when that avatar is inside a `Tooltip`.

<Demo data={groupDemo} />

## API Reference

The avatar, plus a group for stacking several together.
Expand Down
8 changes: 8 additions & 0 deletions apps/www/src/content/docs/components/avatar/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ export interface AvatarProps {
/** Content to display when image fails to load or while loading */
fallback?: React.ReactNode;

/** Milliseconds to wait before showing the fallback */
fallbackDelay?: number;

/** Called when the image loading status changes */
onLoadingStatusChange?: (
status: 'idle' | 'loading' | 'loaded' | 'error'
) => void;

/**
* Visual style variant
* @defaultValue "soft"
Expand Down
59 changes: 58 additions & 1 deletion packages/raystack/components/avatar/__tests__/avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { radiusClasses } from '../../../shared/radius';
import { Tooltip } from '../../tooltip';
import { Avatar, AvatarGroup } from '../avatar';
import styles from '../avatar.module.css';
import { getAvatarColor } from '../utils';
Expand Down Expand Up @@ -55,6 +56,26 @@ describe('Avatar', () => {
);
expect(screen.getByTestId('custom-fallback')).toBeInTheDocument();
});

it('calls onLoadingStatusChange with the image status', async () => {
const onLoadingStatusChange = vi.fn();
render(
<Avatar
src='https://example.com/avatar.png'
alt='JD'
onLoadingStatusChange={onLoadingStatusChange}
/>
);
await waitFor(() =>
expect(onLoadingStatusChange).toHaveBeenCalledWith('loaded')
);
});

it('waits for fallbackDelay before showing the fallback', async () => {
render(<Avatar fallback='JD' fallbackDelay={50} />);
expect(screen.queryByText('JD')).not.toBeInTheDocument();
expect(await screen.findByText('JD')).toBeInTheDocument();
});
});

describe('Sizes', () => {
Expand Down Expand Up @@ -174,6 +195,23 @@ describe('Avatar', () => {
render(<AvatarGroup ref={ref}>{createAvatars(2)}</AvatarGroup>);
expect(ref).toHaveBeenCalled();
});

it('keeps each avatar mounted when the order changes', () => {
const { rerender } = render(
<AvatarGroup>
{[<Avatar key='a' fallback='A' />, <Avatar key='b' fallback='B' />]}
</AvatarGroup>
);
const avatarA = screen.getByText('A').closest('[data-slot="avatar"]');
rerender(
<AvatarGroup>
{[<Avatar key='b' fallback='B' />, <Avatar key='a' fallback='A' />]}
</AvatarGroup>
);
expect(screen.getByText('A').closest('[data-slot="avatar"]')).toBe(
avatarA
);
});
});

describe('Max Property', () => {
Expand Down Expand Up @@ -228,6 +266,25 @@ describe('Avatar', () => {
expect(overflowAvatar).toHaveClass(styles['avatar-size-5']);
});

it('matches the size of a first avatar inside a Tooltip', () => {
render(
<AvatarGroup max={1}>
{[
<Tooltip key={0}>
<Tooltip.Trigger render={<Avatar size={5} fallback='U1' />} />
<Tooltip.Content>User 1</Tooltip.Content>
</Tooltip>,
<Avatar key={1} fallback='U2' />
]}
</AvatarGroup>
);

const overflowAvatar = screen
.getByText('+1')
.closest('[data-slot="avatar"]');
expect(overflowAvatar).toHaveClass(styles['avatar-size-5']);
});

it('matches first avatar radius', () => {
render(
<AvatarGroup max={1}>
Expand Down
30 changes: 13 additions & 17 deletions packages/raystack/components/avatar/avatar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@
--fallback-letter-spacing: 0.03em;
}

.avatar-disabled {
opacity: 0.5;
}

.avatar-solid {
--avatar-opacity: 1;
}
Expand Down Expand Up @@ -395,42 +391,42 @@
}

/* Avatar Group Size Variants */
.avatar-size-1 .avatarWrapper {
.avatarWrapper:has(.avatar-size-1) {
--avatar-overlap: 0.8em;
}
.avatar-size-2 .avatarWrapper {
.avatarWrapper:has(.avatar-size-2) {
--avatar-overlap: 0.9em;
}
.avatar-size-3 .avatarWrapper {
.avatarWrapper:has(.avatar-size-3) {
--avatar-overlap: 1em;
}
.avatar-size-4 .avatarWrapper {
.avatarWrapper:has(.avatar-size-4) {
--avatar-overlap: 1.1em;
}
.avatar-size-5 .avatarWrapper {
.avatarWrapper:has(.avatar-size-5) {
--avatar-overlap: 1.2em;
}
.avatar-size-6 .avatarWrapper {
.avatarWrapper:has(.avatar-size-6) {
--avatar-overlap: 1.3em;
}
.avatar-size-7 .avatarWrapper {
.avatarWrapper:has(.avatar-size-7) {
--avatar-overlap: 1.4em;
}
.avatar-size-8 .avatarWrapper {
.avatarWrapper:has(.avatar-size-8) {
--avatar-overlap: 1.5em;
}
.avatar-size-9 .avatarWrapper {
.avatarWrapper:has(.avatar-size-9) {
--avatar-overlap: 1.6em;
}
.avatar-size-10 .avatarWrapper {
.avatarWrapper:has(.avatar-size-10) {
--avatar-overlap: 1.7em;
}
.avatar-size-11 .avatarWrapper {
.avatarWrapper:has(.avatar-size-11) {
--avatar-overlap: 1.8em;
}
.avatar-size-12 .avatarWrapper {
.avatarWrapper:has(.avatar-size-12) {
--avatar-overlap: 1.9em;
}
.avatar-size-13 .avatarWrapper {
.avatarWrapper:has(.avatar-size-13) {
--avatar-overlap: 2em;
}
50 changes: 23 additions & 27 deletions packages/raystack/components/avatar/avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar';
import { cva, cx, VariantProps } from 'class-variance-authority';
import {
Children,
ComponentProps,
isValidElement,
ReactElement,
ReactNode,
useRef,
useState
Expand Down Expand Up @@ -40,9 +40,6 @@ const avatar = cva(styles.avatar, {
solid: styles['avatar-solid'],
soft: styles['avatar-soft']
},
disabled: {
true: styles['avatar-disabled']
},
color: {
indigo: styles['avatar-color-indigo'],
orange: styles['avatar-color-orange'],
Expand Down Expand Up @@ -132,24 +129,21 @@ const avatar = cva(styles.avatar, {
const image = cva(styles.image);

/*
* @desc Recursively get the avatar props even if it's
* wrapped in another component like Tooltip, Flex, etc.
* @desc Finds the first Avatar in `children` or `render`, so it works
* when wrapped in another component like Tooltip, Flex, etc.
*/
export const getAvatarProps = (
element: ReactElement<AvatarProps>
): AvatarProps => {
const props = element.props as AvatarProps & { children?: ReactNode };

if (element.type === Avatar) {
return props;
export const getAvatarProps = (node: ReactNode): AvatarProps | undefined => {
if (!isValidElement<{ children?: ReactNode; render?: ReactNode }>(node)) {
return undefined;
}
if (node.type === Avatar) return node.props as AvatarProps;

if (props.children) {
if (isValidElement<AvatarProps>(props.children)) {
return getAvatarProps(props.children);
}
const { render, children } = node.props;
for (const child of [render, ...Children.toArray(children)]) {
const props = getAvatarProps(child);
if (props) return props;
}
return {};
return undefined;
};

export interface AvatarProps
Expand All @@ -159,6 +153,8 @@ export interface AvatarProps
src?: string;
alt?: string;
fallback?: ReactNode;
fallbackDelay?: AvatarPrimitive.Fallback.Props['delay'];
onLoadingStatusChange?: AvatarPrimitive.Image.Props['onLoadingStatusChange'];
variant?: 'solid' | 'soft';
color?: AVATAR_COLORS;
className?: string;
Expand All @@ -169,6 +165,8 @@ const AvatarRoot = ({
alt,
src,
fallback,
fallbackDelay,
onLoadingStatusChange,
size,
radius,
variant,
Expand All @@ -178,6 +176,7 @@ const AvatarRoot = ({
const sawLoadingRef = useRef(false);
const [fadeIn, setFadeIn] = useState(false);
const handleLoadingStatusChange = (status: ImageLoadingStatus) => {
onLoadingStatusChange?.(status);
if (status === 'loading') {
sawLoadingRef.current = true;
setFadeIn(false);
Expand Down Expand Up @@ -207,6 +206,7 @@ const AvatarRoot = ({
data-slot='avatar-image'
/>
<AvatarPrimitive.Fallback
delay={fallbackDelay}
className={styles.fallback}
data-slot='avatar-fallback'
>
Expand Down Expand Up @@ -243,21 +243,17 @@ export const AvatarGroup = ({
data-slot='avatar-group'
{...props}
>
{avatars.map((avatar, index) => (
<div
key={index}
className={styles.avatarWrapper}
data-slot='avatar-group-item'
>
{Children.map(avatars, avatar => (
<div className={styles.avatarWrapper} data-slot='avatar-group-item'>
{avatar}
</div>
))}
{count > 0 && (
<div className={styles.avatarWrapper} data-slot='avatar-group-item'>
<Avatar
size={firstAvatarProps.size}
radius={firstAvatarProps.radius}
variant={firstAvatarProps.variant}
size={firstAvatarProps?.size}
radius={firstAvatarProps?.radius}
variant={firstAvatarProps?.variant}
color='neutral'
fallback={`+${count}`}
/>
Expand Down
Loading