Skip to content
Open
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
32 changes: 29 additions & 3 deletions apps/www/src/content/docs/components/avatar/demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,35 @@ export const imageDemo = {
export const generatedColorDemo = {
type: 'code',
code: `
<Flex gap={5} align="end">
<Avatar size={6} color={getAvatarColor("abcde")} fallback="RC" />
</Flex>`
function GeneratedColors() {
const people = [
{ name: "Ravi Chopra", initials: "RC" },
{ name: "Alice", initials: "A" },
{ name: "Bob", initials: "B" },
{ name: "amy", initials: "AM" },
{ name: "may", initials: "MA" }
];

return (
<Flex direction="column" gap={5}>
<Flex gap={5} align="end">
{people.map(({ name, initials }) => (
<Avatar key={name} size={6} color={getAvatarColor(name)} fallback={initials} />
))}
</Flex>
<Flex gap={5} align="end">
{people.map(({ name, initials }) => (
<Avatar
key={name}
size={6}
color={getAvatarColor(name, { palette: ["indigo", "mint", "sky"] })}
fallback={initials}
/>
))}
</Flex>
</Flex>
);
}`
};

export const groupDemo = {
Expand Down
22 changes: 21 additions & 1 deletion apps/www/src/content/docs/components/avatar/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,21 @@ Avatar can display user images with graceful fallback to initials when images fa

### With generated colors

use `getAvatarColor` utility to generate colors based on a string.
`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.

```tsx
getAvatarColor(user.email);

// Pick only from these colors.
getAvatarColor(user.email, { palette: ['indigo', 'mint', 'sky'] });
```

`AVATAR_COLORS` lists every color `getAvatarColor` can return. Use it to build a `palette`, for example to leave out `'neutral'`, which `AvatarGroup` uses for the overflow count:

```tsx
const palette = AVATAR_COLORS.filter(color => color !== 'neutral');
getAvatarColor(user.email, { palette });
```

<Demo data={generatedColorDemo} />

Expand All @@ -94,6 +108,12 @@ Groups multiple avatars with overlap and count indicator.

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

### getAvatarColor

`getAvatarColor(str: string, options?: GetAvatarColorOptions)` returns one of the colors in `AVATAR_COLORS`.

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

### Slots

Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot):
Expand Down
22 changes: 22 additions & 0 deletions apps/www/src/content/docs/components/avatar/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,25 @@ export interface AvatarGroupProps {
/** Additional CSS class names */
className?: string;
}

export interface GetAvatarColorOptions {
/**
* Restricts the result to these colors. Order matters. If empty, all colors are used.
* @defaultValue `AVATAR_COLORS`
*/
palette?: Array<
| 'indigo'
| 'orange'
| 'mint'
| 'neutral'
| 'sky'
| 'lime'
| 'grass'
| 'cyan'
| 'iris'
| 'purple'
| 'pink'
| 'crimson'
| 'gold'
>;
}
78 changes: 44 additions & 34 deletions packages/raystack/components/avatar/__tests__/avatar.test.tsx

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's go easy on the tests which are doing same things multiple times.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The suite is down to 7 tests: anagrams (default and 2-color palette), a known value, empty string, palette membership, full color coverage, and a variant class for every color.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { radiusClasses } from '../../../shared/radius';
import { Tooltip } from '../../tooltip';
import { Avatar, AvatarGroup } from '../avatar';
import styles from '../avatar.module.css';
import { getAvatarColor } from '../utils';
import { AVATAR_COLORS, getAvatarColor } from '../utils';

describe('Avatar', () => {
const ogImage = window.Image;
Expand Down Expand Up @@ -337,43 +337,53 @@ describe('Avatar', () => {

describe('Utility Functions', () => {
describe('getAvatarColor', () => {
it('returns consistent color for same string', () => {
const color1 = getAvatarColor('john.doe@example.com');
const color2 = getAvatarColor('john.doe@example.com');
expect(color1).toBe(color2);
it('maps anagrams to different colors', () => {
expect(getAvatarColor('abc')).toBe('iris');
expect(getAvatarColor('cba')).toBe('neutral');
expect(getAvatarColor('amy')).toBe('neutral');
expect(getAvatarColor('may')).toBe('cyan');
expect(getAvatarColor('night')).toBe('mint');
expect(getAvatarColor('thing')).toBe('purple');
});

it('returns different colors for different strings', () => {
const color1 = getAvatarColor('user1');
const color2 = getAvatarColor('user2');
// While not guaranteed to be different, testing with known different hashes
const colors = new Set([
color1,
color2,
getAvatarColor('user3'),
getAvatarColor('user4')
]);
expect(colors.size).toBeGreaterThan(1);
it('maps anagrams to different colors with a 2-color palette', () => {
const palette = ['sky', 'mint'] as const;
expect(getAvatarColor('amy', { palette })).toBe('mint');
expect(getAvatarColor('may', { palette })).toBe('sky');
});

it('returns valid avatar color', () => {
const validColors = [
'indigo',
'orange',
'mint',
'neutral',
'sky',
'lime',
'grass',
'cyan',
'iris',
'purple',
'pink',
'crimson',
'gold'
];
const color = getAvatarColor('test');
expect(validColors).toContain(color);
it('returns a known color for a known string', () => {
expect(getAvatarColor('john.doe@example.com')).toBe('mint');
});

it('returns a valid color for an empty string', () => {
expect(getAvatarColor('')).toBe('pink');
});

it('returns only colors from the palette', () => {
const palette = ['indigo', 'mint', 'sky'] as const;
for (let i = 0; i < 200; i++) {
expect(palette).toContain(getAvatarColor(`u${i}`, { palette }));
}
});

it('uses every color over many strings', () => {
const hit = new Set(
Array.from({ length: 1000 }, (_, i) => getAvatarColor(`user-${i}`))
);
expect(hit.size).toBe(AVATAR_COLORS.length);
});

it('has a color variant class for every color', () => {
for (const color of AVATAR_COLORS) {
const { container, unmount } = render(
<Avatar color={color} fallback='A' />
);
const className = styles[`avatar-color-${color}`];
expect(className).toBeTruthy();
expect(container.firstElementChild).toHaveClass(className);
unmount();
}
});
});
});
Expand Down
4 changes: 2 additions & 2 deletions packages/raystack/components/avatar/avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
} from 'react';
import { radiusVariants } from '../../shared/radius';
import styles from './avatar.module.css';
import { AVATAR_COLORS } from './utils';
import type { AvatarColor } from './utils';

// Matches Base UI's AvatarRoot ImageLoadingStatus union.
type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
Expand Down Expand Up @@ -156,7 +156,7 @@ export interface AvatarProps
fallbackDelay?: AvatarPrimitive.Fallback.Props['delay'];
onLoadingStatusChange?: AvatarPrimitive.Image.Props['onLoadingStatusChange'];
variant?: 'solid' | 'soft';
color?: AVATAR_COLORS;
color?: AvatarColor;
className?: string;
}

Expand Down
7 changes: 6 additions & 1 deletion packages/raystack/components/avatar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,7 @@
export { Avatar, AvatarGroup } from './avatar';
export { getAvatarColor } from './utils';
export {
AVATAR_COLORS,
type AvatarColor,
type GetAvatarColorOptions,
getAvatarColor
} from './utils';
29 changes: 23 additions & 6 deletions packages/raystack/components/avatar/utils.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const COLORS = [
export const AVATAR_COLORS = [
'indigo',
'orange',
'mint',
Expand All @@ -14,10 +14,27 @@ export const COLORS = [
'gold'
] as const;

export type AVATAR_COLORS = (typeof COLORS)[number];
export type AvatarColor = (typeof AVATAR_COLORS)[number];

export function getAvatarColor(str: string): AVATAR_COLORS {
const hash = str.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
const index = hash % COLORS.length;
return COLORS[index];
export interface GetAvatarColorOptions {
/** Restricts the result to these colors. Order matters. If empty, all colors are used. */
palette?: readonly AvatarColor[];
}

export function getAvatarColor(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's move the FNV-1a loop inline in getAvatarColor function. Let's not make a separate export for it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The loop and the bit-mix are inline in getAvatarColor. shared/hash is deleted and the data-view test helper is back to the main version. The outputs are unchanged.

str: string,
{ palette }: GetAvatarColorOptions = {}
): AvatarColor {
const colors = palette?.length ? palette : AVATAR_COLORS;
// 32-bit FNV-1a
let hash = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
hash ^= str.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
// The lowest bit of FNV-1a is an XOR of each character's lowest bit, so it
// ignores order. Mixing the high bits in keeps a 2-color palette order-sensitive.
hash ^= hash >>> 16;
hash = Math.imul(hash, 0x45d9f3b) >>> 0;
return colors[hash % colors.length];
}
9 changes: 8 additions & 1 deletion packages/raystack/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,14 @@ export { Accordion } from './components/accordion';
export { AlertDialog } from './components/alert-dialog';
export { Amount, type AmountProps } from './components/amount';
export { AnnouncementBar } from './components/announcement-bar';
export { Avatar, AvatarGroup, getAvatarColor } from './components/avatar';
export {
AVATAR_COLORS,
Avatar,
type AvatarColor,
AvatarGroup,
type GetAvatarColorOptions,
getAvatarColor
} from './components/avatar';
export { Badge } from './components/badge';
export { Box } from './components/box';
export { Breadcrumb } from './components/breadcrumb';
Expand Down
Loading