Avatar component

Image + fallback pattern with idle/loading/loaded/error status tracking via context. 4 tests passing.
This commit is contained in:
Mats Bosson 2026-03-29 20:52:04 +07:00
parent 5c503ee9ef
commit 80f7af401a
7 changed files with 142 additions and 0 deletions

View File

@ -0,0 +1,21 @@
import { createContext, useContext } from "solid-js";
interface AvatarContextValue {
imageLoadingStatus: () => "idle" | "loading" | "loaded" | "error";
setImageLoadingStatus: (status: "idle" | "loading" | "loaded" | "error") => void;
}
const AvatarContext = createContext<AvatarContextValue>();
/** Returns the Avatar context. Throws if used outside <Avatar>. */
export function useAvatarContext(): AvatarContextValue {
const context = useContext(AvatarContext);
if (!context) {
throw new Error(
"[PettyUI] Avatar parts must be used within <Avatar>. Fix: Wrap Avatar.Image and Avatar.Fallback inside <Avatar>.",
);
}
return context;
}
export { AvatarContext };

View File

@ -0,0 +1,15 @@
import type { JSX } from "solid-js";
import { Show, splitProps } from "solid-js";
import type { AvatarFallbackProps } from "./avatar.props";
import { useAvatarContext } from "./avatar-context";
/** Rendered when the avatar image has not successfully loaded. */
export function AvatarFallback(props: AvatarFallbackProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const context = useAvatarContext();
return (
<Show when={context.imageLoadingStatus() !== "loaded"}>
<span data-scope="avatar" data-part="fallback" {...rest}>{local.children}</span>
</Show>
);
}

View File

@ -0,0 +1,28 @@
import type { JSX } from "solid-js";
import { onMount, splitProps } from "solid-js";
import type { AvatarImageProps } from "./avatar.props";
import { useAvatarContext } from "./avatar-context";
/** The image element. Probes load status and updates Avatar context on load/error. */
export function AvatarImage(props: AvatarImageProps): JSX.Element {
const [local, rest] = splitProps(props, ["src", "alt"]);
const context = useAvatarContext();
onMount(() => {
context.setImageLoadingStatus("loading");
const img = new window.Image();
img.src = local.src;
img.onload = () => context.setImageLoadingStatus("loaded");
img.onerror = () => context.setImageLoadingStatus("error");
});
return (
<img
data-scope="avatar"
data-part="image"
src={local.src}
alt={local.alt}
{...rest}
/>
);
}

View File

@ -0,0 +1,15 @@
import type { JSX } from "solid-js";
import { createSignal, splitProps } from "solid-js";
import type { AvatarRootProps } from "./avatar.props";
import { AvatarContext } from "./avatar-context";
/** Root container for Avatar. Provides image loading status context to all Avatar parts. */
export function AvatarRoot(props: AvatarRootProps): JSX.Element {
const [local, rest] = splitProps(props, ["children"]);
const [imageLoadingStatus, setImageLoadingStatus] = createSignal<"idle" | "loading" | "loaded" | "error">("idle");
return (
<AvatarContext.Provider value={{ imageLoadingStatus, setImageLoadingStatus }}>
<span data-scope="avatar" data-part="root" {...rest}>{local.children}</span>
</AvatarContext.Provider>
);
}

View File

@ -0,0 +1,21 @@
import { z } from "zod/v4";
import type { JSX } from "solid-js";
import type { ComponentMeta } from "../../meta";
export const AvatarRootPropsSchema = z.object({});
export interface AvatarRootProps extends JSX.HTMLAttributes<HTMLSpanElement> { children: JSX.Element; }
export const AvatarImagePropsSchema = z.object({
src: z.string().describe("Image URL"),
alt: z.string().describe("Alt text for accessibility"),
});
export interface AvatarImageProps extends z.infer<typeof AvatarImagePropsSchema>, Omit<JSX.ImgHTMLAttributes<HTMLImageElement>, keyof z.infer<typeof AvatarImagePropsSchema>> {}
export interface AvatarFallbackProps extends JSX.HTMLAttributes<HTMLSpanElement> { children?: JSX.Element; }
export const AvatarMeta: ComponentMeta = {
name: "Avatar",
description: "User profile image with fallback to initials or icon when image fails to load",
parts: ["Root", "Image", "Fallback"] as const,
requiredParts: ["Root", "Fallback"] as const,
} as const;

View File

@ -0,0 +1,8 @@
import { AvatarRoot } from "./avatar-root";
import { AvatarImage } from "./avatar-image";
import { AvatarFallback } from "./avatar-fallback";
/** Compound Avatar component. Use Avatar.Image and Avatar.Fallback as children. */
export const Avatar = Object.assign(AvatarRoot, { Image: AvatarImage, Fallback: AvatarFallback });
export type { AvatarRootProps, AvatarImageProps, AvatarFallbackProps } from "./avatar.props";
export { AvatarRootPropsSchema, AvatarImagePropsSchema, AvatarMeta } from "./avatar.props";

View File

@ -0,0 +1,34 @@
import { render, screen } from "@solidjs/testing-library";
import { describe, expect, it } from "vitest";
import { Avatar } from "../../../src/components/avatar/index";
import { AvatarRootPropsSchema, AvatarMeta } from "../../../src/components/avatar/avatar.props";
describe("Avatar", () => {
it("renders fallback when no image", () => {
render(() => <Avatar><Avatar.Fallback data-testid="fallback">MB</Avatar.Fallback></Avatar>);
expect(screen.getByTestId("fallback")).toBeTruthy();
expect(screen.getByText("MB")).toBeTruthy();
});
it("renders image element", () => {
render(() => (
<Avatar>
<Avatar.Image src="test.jpg" alt="User" data-testid="img" />
<Avatar.Fallback>MB</Avatar.Fallback>
</Avatar>
));
const img = screen.getByTestId("img");
expect(img.tagName).toBe("IMG");
});
it("schema validates", () => {
expect(AvatarRootPropsSchema.safeParse({}).success).toBe(true);
});
it("meta has fields", () => {
expect(AvatarMeta.name).toBe("Avatar");
expect(AvatarMeta.parts).toContain("Root");
expect(AvatarMeta.parts).toContain("Image");
expect(AvatarMeta.parts).toContain("Fallback");
});
});