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
2 changes: 2 additions & 0 deletions apps/www/src/content/docs/components/avatar/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ Avatar can display user images with graceful fallback to initials when images fa

<Demo data={imageDemo} />

The `<img>` renders right away and loads in place, so an image in server-rendered HTML can show before hydration. While the image loads, or after it fails, the `<img>` is hidden and the fallback shows. A load that takes 100ms or more fades in. A faster load, such as one from the cache, appears at once. The fallback stays hidden for the first 100ms of a load, so cached images do not flash the initials.

### With generated colors

`getAvatarColor` derives a color from a string, such as a user's email. The same string always returns the same color, on the server and in the browser. Pass a stable ID, such as `user.id`, so the color does not change when someone is renamed.
Expand Down
6 changes: 6 additions & 0 deletions apps/www/src/content/docs/components/toast/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ The returned object exposes the four manager methods:

The `update(id, options)` method accepts a partial of the toast, with every field optional, used to patch an existing toast in place.

To build the update from the current toast, pass a function. It receives the current toast and returns the update options.

```tsx
toastManager.update(id, prev => ({ title: `${prev.title} (retrying)` }));
```

<auto-type-table path="./props.ts" name="ToastManagerUpdateOptions" />

#### Promise options
Expand Down
16 changes: 12 additions & 4 deletions apps/www/src/content/docs/components/toast/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ export interface ToastObject {
type?: 'success' | 'error' | 'info' | 'warning' | 'loading';
timeout?: number;
priority?: 'low' | 'high';
/** Extra toast data. `leadingIcon` holds the custom icon, if one was set. */
data?: { leadingIcon?: React.ReactNode };
}

/**
Expand All @@ -172,11 +174,17 @@ export interface CreateToastManagerReturn {

/**
* Update an existing toast in place. Typically used to swap a loading
* toast to a success or error state without dismissing it.
* toast to a success or error state without dismissing it. Pass a
* function to derive the update from the current toast.
*
* @remarks `(id: string, options: ToastManagerUpdateOptions) => void`
*/
update: (id: string, options: ToastManagerUpdateOptions) => void;
* @remarks `(id: string, options: ToastManagerUpdateOptions | ((prevToast: ToastObject) => ToastManagerUpdateOptions)) => void`
*/
update: (
id: string,
options:
| ToastManagerUpdateOptions
| ((prevToast: ToastObject) => ToastManagerUpdateOptions)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) => void;

/**
* Tie a toast's lifecycle to a promise. Renders the `loading` toast
Expand Down
124 changes: 56 additions & 68 deletions packages/react/components/avatar/__tests__/avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,34 @@
import { render, screen, waitFor } from '@testing-library/react';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { 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 { AVATAR_COLORS, getAvatarColor } from '../utils';

describe('Avatar', () => {
const ogImage = window.Image;

beforeAll(() => {
window.Image = MockImage as unknown as typeof Image;
});

afterAll(() => {
window.Image = ogImage;
});
const SRC = 'https://example.com/avatar.png';

describe('Avatar', () => {
describe('Basic Rendering', () => {
it('renders with image source', async () => {
const src =
'https://images.unsplash.com/photo-1511485977113-f34c92461ad9';
const rendered = render(
<Avatar src={src} alt='John Doe' fallback='JD' />
);
it('renders the image before it loads, hidden behind the fallback', () => {
const { container } = render(<Avatar src={SRC} alt='JD' fallback='JD' />);
const img = container.querySelector('img');
expect(img).toHaveAttribute('src', SRC);
expect(img).toHaveAttribute('data-loading');
expect(img).toHaveAttribute('aria-hidden', 'true');
expect(screen.getByText('JD')).toBeInTheDocument();
});

it('shows the image once it loads', async () => {
const { container } = render(<Avatar src={SRC} alt='JD' fallback='JD' />);
fireEvent.load(container.querySelector('img')!);
expect(await screen.findByRole('img', { name: 'JD' })).toBeVisible();
expect(screen.queryByText('JD')).not.toBeInTheDocument();
});

const img = await rendered.findByRole('img');
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute('src', src);
it('renders no image without src', () => {
const { container } = render(<Avatar fallback='JD' />);
expect(container.querySelector('img')).toBeNull();
});

it('applies custom className', () => {
Expand Down Expand Up @@ -59,18 +60,49 @@ describe('Avatar', () => {

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

it('marks the image as failed on error', async () => {
const { container } = render(<Avatar src={SRC} alt='JD' fallback='JD' />);
const img = container.querySelector('img')!;
fireEvent.error(img);
await waitFor(() => expect(img).toHaveAttribute('data-error'));
expect(screen.getByText('JD')).toBeInTheDocument();
});

it('skips the fade for a fast load', async () => {
const now = vi.spyOn(performance, 'now').mockReturnValue(0);
const { container } = render(<Avatar src={SRC} alt='JD' />);
const img = container.querySelector('img')!;
now.mockReturnValue(20);
fireEvent.load(img);
await waitFor(() => expect(img).not.toHaveAttribute('data-loading'));
expect(img).not.toHaveClass(styles['image-fade-in']);
now.mockRestore();
});

it('fades in after a slow load', async () => {
const now = vi.spyOn(performance, 'now').mockReturnValue(0);
const { container } = render(<Avatar src={SRC} alt='JD' />);
const img = container.querySelector('img')!;
now.mockReturnValue(500);
fireEvent.load(img);
await waitFor(() => expect(img).toHaveClass(styles['image-fade-in']));
now.mockRestore();
});

it('waits for fallbackDelay before showing the fallback', async () => {
render(<Avatar fallback='JD' fallbackDelay={50} />);
expect(screen.queryByText('JD')).not.toBeInTheDocument();
Expand Down Expand Up @@ -358,47 +390,3 @@ describe('Avatar', () => {
});
});
});

class MockImage extends EventTarget {
_src: string = '';
_complete: boolean = false;
onload: (() => void) | null = null;

constructor() {
super();
return this;
}

get src() {
return this._src;
}

set src(src: string) {
if (!src) {
return;
}
this._src = src;
// Simulate async image loading
setTimeout(() => {
this._complete = true;
// Call onload callback if set
if (this.onload) {
this.onload();
}
// Also dispatch the event
this.dispatchEvent(new Event('load'));
}, 0);
}

get complete() {
return this._complete;
}

get naturalWidth() {
return this._complete ? 300 : 0;
}

get naturalHeight() {
return this._complete ? 300 : 0;
}
}
51 changes: 4 additions & 47 deletions packages/react/components/avatar/__tests__/data-slots.test.tsx
Original file line number Diff line number Diff line change
@@ -1,64 +1,21 @@
import { render } from '@testing-library/react';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { fireEvent, render } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { expectSlots, getAllSlots, getSlot } from '~/test-utils/data-slots';
import { Avatar, AvatarGroup } from '../avatar';

// jsdom has no real image decoder; simulate a successful async load so
// Base UI's AvatarPrimitive.Image moves past the loading status and renders.
class MockImage extends EventTarget {
_src: string = '';
_complete: boolean = false;
onload: (() => void) | null = null;

get src() {
return this._src;
}

set src(src: string) {
if (!src) return;
this._src = src;
setTimeout(() => {
this._complete = true;
this.onload?.();
this.dispatchEvent(new Event('load'));
}, 0);
}

get complete() {
return this._complete;
}

get naturalWidth() {
return this._complete ? 300 : 0;
}

get naturalHeight() {
return this._complete ? 300 : 0;
}
}

describe('Avatar data-slot contract', () => {
const ogImage = window.Image;

beforeAll(() => {
window.Image = MockImage as unknown as typeof Image;
});

afterAll(() => {
window.Image = ogImage;
});

it('exposes the fallback slot before the image loads', () => {
const { container } = render(
<Avatar src='https://example.com/avatar.png' alt='JD' fallback='JD' />
);
expectSlots(container, ['avatar', 'avatar-fallback']);
expectSlots(container, ['avatar', 'avatar-image', 'avatar-fallback']);
});

it('exposes the image slot once loaded, replacing the fallback', async () => {
const rendered = render(
<Avatar src='https://example.com/avatar.png' alt='JD' fallback='JD' />
);
fireEvent.load(rendered.container.querySelector('img')!);
await rendered.findByRole('img');
expectSlots(rendered.container, ['avatar', 'avatar-image']);
expect(
Expand Down
19 changes: 19 additions & 0 deletions packages/react/components/avatar/avatar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,25 @@
width: 100%;
height: 100%;
vertical-align: middle;
/* The img is mounted before it loads, so it sits on top of the fallback instead of next to it. */
position: absolute;
inset: 0;
}

.image[data-loading],
.image[data-error] {
opacity: 0;
}

/* Hide the fallback briefly so cached images don't flash the initials first. */
.image[data-loading] ~ .fallback {
animation: avatar-fallback-delay 0s 100ms backwards;
}

@keyframes avatar-fallback-delay {
from {
visibility: hidden;
}
}

@keyframes avatar-image-in {
Expand Down
34 changes: 21 additions & 13 deletions packages/react/components/avatar/avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import type { AvatarColor } from './utils';
// Matches Base UI's AvatarRoot ImageLoadingStatus union.
type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';

// Loads faster than this come from the cache and appear without a fade.
// Matches the fallback hide delay in avatar.module.css.
const FADE_IN_THRESHOLD_MS = 100;

const avatar = cva(styles.avatar, {
variants: {
// Each size names its own base step. No default: the theme radius applies.
Expand Down Expand Up @@ -173,18 +177,19 @@ const AvatarRoot = ({
color,
...props
}: AvatarProps) => {
const sawLoadingRef = useRef(false);
const loadStartRef = useRef<number | null>(null);
const [fadeIn, setFadeIn] = useState(false);
const handleLoadingStatusChange = (status: ImageLoadingStatus) => {
onLoadingStatusChange?.(status);
if (status === 'loading') {
sawLoadingRef.current = true;
loadStartRef.current = performance.now();
setFadeIn(false);
} else if (status === 'loaded') {
// A cached image reports 'loaded' with no 'loading' phase (Base UI never
// emits it), so it stays instant; only network loads fade in.
setFadeIn(sawLoadingRef.current);
sawLoadingRef.current = false;
const start = loadStartRef.current;
setFadeIn(
start !== null && performance.now() - start >= FADE_IN_THRESHOLD_MS
);
loadStartRef.current = null;
}
};

Expand All @@ -198,13 +203,16 @@ const AvatarRoot = ({
data-slot='avatar'
{...props}
>
<AvatarPrimitive.Image
className={cx(image(), fadeIn && styles['image-fade-in'])}
src={src}
alt={alt}
onLoadingStatusChange={handleLoadingStatusChange}
data-slot='avatar-image'
/>
{src && (
<AvatarPrimitive.Image
className={cx(image(), fadeIn && styles['image-fade-in'])}
src={src}
alt={alt}
onLoadingStatusChange={handleLoadingStatusChange}
keepMounted
data-slot='avatar-image'
/>
)}
<AvatarPrimitive.Fallback
delay={fallbackDelay}
className={styles.fallback}
Expand Down
Loading
Loading