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
96 changes: 79 additions & 17 deletions packages/propel/src/components/avatar/avatar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import * as React from "react";
import { expect, waitFor } from "storybook/test";

import { AVATAR_TONES, type AvatarMagnitude, Avatar } from "./index";
import { type AvatarMagnitude, Avatar } from "./index";

const MAGNITUDES: AvatarMagnitude[] = ["2xs", "xs", "sm", "md", "lg", "xl", "2xl", "3xl"];

Expand All @@ -11,6 +11,11 @@ const PHOTO_SRC = `data:image/svg+xml,${encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" fill="#7dd3fc"/><circle cx="44" cy="18" r="8" fill="#fde047"/><path d="M0 64 24 34l14 16 10-10 16 24Z" fill="#16a34a"/></svg>',
)}`;

// A `src` that is present but undecodable — the browser still attempts to load it and fires a
// genuine `error` event, unlike an absent `src` (which Base UI never attempts to load at all).
// Malformed inline data needs no network, so the failure is deterministic in any environment.
const BROKEN_SRC = "data:image/png;base64,not-a-real-image";

const meta = {
title: "Components/Avatar",
component: Avatar,
Expand All @@ -25,6 +30,15 @@ const meta = {
url: "https://www.figma.com/design/ioN74zM1xMGbcPemsxs4J1/Global-components?node-id=40-46",
},
},
// Every story is one or more avatars in a row; a single avatar just centers trivially inside it,
// so one decorator covers every story in this file instead of each `render` repeating the div.
decorators: [
(Story) => (
<div className="flex items-center gap-3">
<Story />
</div>
),
],
} satisfies Meta<typeof Avatar>;

export default meta;
Expand All @@ -37,41 +51,66 @@ export const Magnitudes: Story = {
// update every avatar at once.
argTypes: { magnitude: { control: false } },
render: (args) => (
<div className="flex items-center gap-3">
<>
{MAGNITUDES.map((magnitude) => (
<Avatar key={magnitude} {...args} magnitude={magnitude} />
))}
</div>
</>
),
};

// Initials-only avatars with NO `alt`, each with different initials. Every one seeds its tone from
// its own initials, so they spread across the palette instead of collapsing onto one color — the
// bug when the seed was `alt ?? ""` (empty string → always the first tone). Same initials would
// still yield the same color: the assignment stays stable, it's just no longer name-dependent.
const NO_ALT_INITIALS = ["NN", "AD", "MK", "AR", "JD", "AB"];

/**
* The initials background follows `tone`. When `tone` is omitted it's derived from `alt`, so every
* person gets a stable color automatically.
* With no `alt`, tone is derived from each avatar's initials — so a row of unnamed avatars still
* gets a varied palette rather than all sharing one color.
*/
export const Tones: Story = {
args: { src: undefined },
// Iterates `tone` and derives each label (`fallback`) from the tone name, so
// disable those two controls; the rest stay live and update every avatar at once.
argTypes: { tone: { control: false }, fallback: { control: false } },
render: (args) => (
<div className="flex items-center gap-3">
{AVATAR_TONES.map((tone) => (
<Avatar key={tone} {...args} tone={tone} magnitude="lg" fallback={tone[0]?.toUpperCase()} />
export const NoAltDistinctTones: Story = {
parameters: { controls: { disable: true } },
render: () => (
<>
{NO_ALT_INITIALS.map((initials) => (
<Avatar key={initials} magnitude="lg" fallback={initials} />
))}
</div>
</>
),
};

/**
* Behavior twin of `NoAltDistinctTones`: none of the avatars has an `alt`, yet their initials
* surfaces resolve to more than one color. Before the fix every seedless avatar hashed `""` to the
* same tone, so this set would have collapsed to size 1. Tagged out of the sidebar/docs/manifest
* while still running under the default `test` tag.
*/
export const NoAltDistinctTonesInteraction: Story = {
...NoAltDistinctTones,
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas }) => {
const colors = NO_ALT_INITIALS.map(
(initials) => getComputedStyle(canvas.getByText(initials)).backgroundColor,
);
// Every color is a real compiled tone (not transparent)…
for (const color of colors) {
await expect(color).not.toBe("rgba(0, 0, 0, 0)");
}
// …and they are not all the same — the seedless-collapse regression is gone.
await expect(new Set(colors).size).toBeGreaterThan(1);
},
};

/** The three states side by side: image, initials, and the anonymous person icon. */
export const States: Story = {
parameters: { controls: { disable: true } },
render: (args) => (
<div className="flex items-center gap-3">
<>
<Avatar {...args} magnitude="lg" src="https://i.pravatar.cc/128?img=47" />
<Avatar {...args} magnitude="lg" src={undefined} />
<Avatar {...args} magnitude="lg" src={undefined} fallback={undefined} />
</div>
</>
),
};

Expand Down Expand Up @@ -114,6 +153,29 @@ export const DelayedFallbackInteraction: Story = {
},
};

/**
* A `src` that fails to load — distinct from an absent `src` (the `States`/`Tones` stories above),
* which never attempts to load an image at all. The initials fallback takes over once the load
* errors.
*/
export const BrokenImage: Story = {
args: { src: BROKEN_SRC },
};

/**
* Behavior twin of `BrokenImage`: the failed load never leaves an `<img>` in the DOM, and the
* initials fallback renders in its place. Tagged out of the sidebar/docs/manifest while still
* running under the default `test` tag.
*/
export const BrokenImageInteraction: Story = {
...BrokenImage,
tags: ["!dev", "!autodocs", "!manifest"],
play: async ({ canvas, canvasElement }) => {
await waitFor(() => expect(canvas.getByText("AL")).toBeInTheDocument());
await waitFor(() => expect(canvasElement.querySelector("img")).not.toBeInTheDocument());
},
};

/**
* The single project-wide CSS check: an `md` avatar is `size-7` (28px) and the tone utility
* resolves to a real color. Concrete computed values prove the shared preview actually compiled
Expand Down
45 changes: 22 additions & 23 deletions packages/propel/src/components/avatar/avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import * as React from "react";
import {
Avatar as AvatarElement,
AvatarFallback,
AvatarIcon,
AvatarImage,
type AvatarMagnitude,
type AvatarProps as AvatarElementProps,
type AvatarTone,
getAvatarTone,
getAvatarToneSeed,
} from "../../elements/avatar";
import { Icon } from "../../internal/icon";
import { AvatarGroupContext } from "./avatar-group-context";

export type AvatarProps = Omit<AvatarElementProps, "magnitude"> & {
Expand All @@ -23,46 +23,45 @@ export type AvatarProps = Omit<AvatarElementProps, "magnitude"> & {
alt?: string;
/** Initials shown when there is no image. When omitted too, a person icon shows. */
fallback?: React.ReactNode;
/** Initials background color. Defaults to a stable color derived from `alt`. */
tone?: AvatarTone;
/** Milliseconds before the fallback shows, to avoid a flash while `src` loads quickly. */
delay?: number;
};

/**
* The ready-made avatar: an image that falls back to initials (or an anonymous person icon),
* composed from the `elements/avatar` parts (`Avatar` root + `AvatarImage` + `AvatarFallback`).
* Pass `src` for the photo, `fallback` for initials, and optionally `tone` (otherwise derived from
* `alt`).
* Pass `src` for the photo and `fallback` for initials; the initials color is chosen automatically
* and is not a consumer prop.
*/
export function Avatar({ magnitude, src, alt, fallback, tone, delay, ...props }: AvatarProps) {
export function Avatar({ magnitude, src, alt, fallback, delay, ...props }: AvatarProps) {
// Base UI shows the fallback whenever the image is absent, loading, or failed, so the
// colored-initials styling lives on the Fallback element itself. Initials = a label tone
// color; the anonymous person icon renders in the icon slot over the root's neutral backdrop
// (there is no "none" tone).
// colored-initials styling lives on the Fallback element itself. The anonymous person icon
// renders in the icon slot over the root's neutral backdrop when there are no initials.
const hasInitials = fallback != null;
const groupMagnitude = React.useContext(AvatarGroupContext);
const effectiveMagnitude = magnitude ?? groupMagnitude ?? "md";
// The tone is auto-derived from the name unless explicitly set, so each person gets a
// stable color without the caller having to choose one.
const resolvedTone = tone ?? getAvatarTone(alt ?? "");
// The anonymous glyph is the shared `Icon` (muted, static — no input-focus brightening), sized by
// the `--node-size` the root sets per magnitude, so there is no avatar-specific icon part.
// Tone is always system-chosen (never a consumer prop): seed a stable color from the name, else
// the initials text, so unnamed avatars vary by initials instead of collapsing onto one color.
const resolvedTone = getAvatarTone(getAvatarToneSeed(alt, fallback));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

alt is optional, so this can render role="img" without an accessible name. Could we require/provide an accessible label for this state instead?

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.

Instead of requiring alt, made the avatar named or decorative:

  • alt set → role="img" + aria-label (unchanged)
  • alt omitted → aria-hidden, so AT skips the decorative circle instead of announcing a nameless image
    const a11y = alt != null ? { role: "img", "aria-label": alt } : { "aria-hidden": true };
    Kept alt optional since avatars usually sit next to a visible name. Applied to both Avatar and WorkspaceAvatar; also dropped the role-img-alt axe suppression the no-alt stories needed — it's genuinely fixed now, axe passes clean.

// Named vs decorative: with an `alt`, expose one accessible name for every state
// (image / initials / icon) via `role="img"`; without one, mark the avatar `aria-hidden` so it is
// skipped rather than announced as a nameless image (the name lives in adjacent text). This is the
// only correct pair — a `role="img"` with no name is an axe violation.
const a11y = alt != null ? { role: "img", "aria-label": alt } : { "aria-hidden": true };
return (
// `role="img"` + `aria-label` give the avatar one accessible name in every state
// (image / initials / icon); the inner image is decorative. Base UI `Avatar` behavior/context
// grafts onto the styled `elements/avatar` parts via `render` (behavior part outer).
<BaseAvatar.Root
role="img"
aria-label={alt}
{...props}
render={<AvatarElement magnitude={effectiveMagnitude} />}
>
// Base UI `Avatar` behavior/context grafts onto the styled `elements/avatar` parts via `render`
// (behavior part outer). `{...props}` spreads before the hardcoded a11y attrs so a stray
// same-named prop can never silently override them (matches `components/workspace-avatar`).
<BaseAvatar.Root {...props} render={<AvatarElement magnitude={effectiveMagnitude} />} {...a11y}>
{src ? <BaseAvatar.Image render={<AvatarImage />} src={src} alt="" /> : null}
{hasInitials ? (
<BaseAvatar.Fallback delay={delay} render={<AvatarFallback tone={resolvedTone} />}>
{fallback}
</BaseAvatar.Fallback>
) : (
<BaseAvatar.Fallback delay={delay} render={<AvatarIcon magnitude={effectiveMagnitude} />}>
<BaseAvatar.Fallback delay={delay} render={<Icon tint="muted" />}>
<User />
</BaseAvatar.Fallback>
)}
Expand Down
7 changes: 4 additions & 3 deletions packages/propel/src/components/avatar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./avatar";
// Re-export the avatar tone palette + magnitude type so the components-tier story (and
// consumers) can reach them without dropping to `elements/avatar`.
export { AVATAR_TONES, type AvatarMagnitude, type AvatarTone } from "../../elements/avatar";
// Re-export the magnitude type so consumers can size an avatar without dropping to
// `elements/avatar`. Tone is intentionally NOT re-exported — the initials color is system-chosen,
// not a consumer prop.
export { type AvatarMagnitude } from "../../elements/avatar";
6 changes: 3 additions & 3 deletions packages/propel/src/components/workspace-avatar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export * from "./workspace-avatar";
// Re-export the avatar tone palette + the workspace magnitude type so the components-tier
// story (and consumers) can reach them without dropping to the elements tier.
export { AVATAR_TONES, type AvatarTone } from "../../elements/avatar";
// Re-export the magnitude type so consumers can size the avatar without dropping to the elements
// tier. Tone is intentionally NOT re-exported — the initials color is system-chosen, not a
// consumer prop.
export { type WorkspaceAvatarMagnitude } from "../../elements/workspace-avatar";
Loading