diff --git a/apps/www/src/content/docs/components/avatar/demo.ts b/apps/www/src/content/docs/components/avatar/demo.ts index 8505fbcf4..75109d6e0 100644 --- a/apps/www/src/content/docs/components/avatar/demo.ts +++ b/apps/www/src/content/docs/components/avatar/demo.ts @@ -133,9 +133,35 @@ export const imageDemo = { export const generatedColorDemo = { type: 'code', code: ` - - - ` + 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 ( + + + {people.map(({ name, initials }) => ( + + ))} + + + {people.map(({ name, initials }) => ( + + ))} + + + ); + }` }; export const groupDemo = { diff --git a/apps/www/src/content/docs/components/avatar/index.mdx b/apps/www/src/content/docs/components/avatar/index.mdx index 55157b8cb..ae3df023a 100644 --- a/apps/www/src/content/docs/components/avatar/index.mdx +++ b/apps/www/src/content/docs/components/avatar/index.mdx @@ -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 }); +``` @@ -94,6 +108,12 @@ Groups multiple avatars with overlap and count indicator. +### getAvatarColor + +`getAvatarColor(str: string, options?: GetAvatarColorOptions)` returns one of the colors in `AVATAR_COLORS`. + + + ### Slots Every rendered part carries a stable `data-slot` attribute for [styling and testing](/docs/styling#with-data-slot): diff --git a/apps/www/src/content/docs/components/avatar/props.ts b/apps/www/src/content/docs/components/avatar/props.ts index 74d84977b..964bbdbe2 100644 --- a/apps/www/src/content/docs/components/avatar/props.ts +++ b/apps/www/src/content/docs/components/avatar/props.ts @@ -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' + >; +} diff --git a/packages/raystack/components/avatar/__tests__/avatar.test.tsx b/packages/raystack/components/avatar/__tests__/avatar.test.tsx index ad08811aa..60917cfc0 100644 --- a/packages/raystack/components/avatar/__tests__/avatar.test.tsx +++ b/packages/raystack/components/avatar/__tests__/avatar.test.tsx @@ -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; @@ -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( + + ); + const className = styles[`avatar-color-${color}`]; + expect(className).toBeTruthy(); + expect(container.firstElementChild).toHaveClass(className); + unmount(); + } }); }); }); diff --git a/packages/raystack/components/avatar/avatar.tsx b/packages/raystack/components/avatar/avatar.tsx index c145b2ce2..7b4a576bc 100644 --- a/packages/raystack/components/avatar/avatar.tsx +++ b/packages/raystack/components/avatar/avatar.tsx @@ -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'; @@ -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; } diff --git a/packages/raystack/components/avatar/index.tsx b/packages/raystack/components/avatar/index.tsx index 80be713d5..eccf8984c 100644 --- a/packages/raystack/components/avatar/index.tsx +++ b/packages/raystack/components/avatar/index.tsx @@ -1,2 +1,7 @@ export { Avatar, AvatarGroup } from './avatar'; -export { getAvatarColor } from './utils'; +export { + AVATAR_COLORS, + type AvatarColor, + type GetAvatarColorOptions, + getAvatarColor +} from './utils'; diff --git a/packages/raystack/components/avatar/utils.tsx b/packages/raystack/components/avatar/utils.tsx index 7d96f9f2d..59fb72161 100644 --- a/packages/raystack/components/avatar/utils.tsx +++ b/packages/raystack/components/avatar/utils.tsx @@ -1,4 +1,4 @@ -export const COLORS = [ +export const AVATAR_COLORS = [ 'indigo', 'orange', 'mint', @@ -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( + 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]; } diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 89156ac6e..c8018c342 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -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';