From 80f7af401abdff1f7f21615a6dcbba6a01300536 Mon Sep 17 00:00:00 2001 From: Mats Bosson Date: Sun, 29 Mar 2026 20:52:04 +0700 Subject: [PATCH] Avatar component Image + fallback pattern with idle/loading/loaded/error status tracking via context. 4 tests passing. --- .../src/components/avatar/avatar-context.ts | 21 ++++++++++++ .../src/components/avatar/avatar-fallback.tsx | 15 ++++++++ .../src/components/avatar/avatar-image.tsx | 28 +++++++++++++++ .../src/components/avatar/avatar-root.tsx | 15 ++++++++ .../src/components/avatar/avatar.props.ts | 21 ++++++++++++ packages/core/src/components/avatar/index.ts | 8 +++++ .../tests/components/avatar/avatar.test.tsx | 34 +++++++++++++++++++ 7 files changed, 142 insertions(+) create mode 100644 packages/core/src/components/avatar/avatar-context.ts create mode 100644 packages/core/src/components/avatar/avatar-fallback.tsx create mode 100644 packages/core/src/components/avatar/avatar-image.tsx create mode 100644 packages/core/src/components/avatar/avatar-root.tsx create mode 100644 packages/core/src/components/avatar/avatar.props.ts create mode 100644 packages/core/src/components/avatar/index.ts create mode 100644 packages/core/tests/components/avatar/avatar.test.tsx diff --git a/packages/core/src/components/avatar/avatar-context.ts b/packages/core/src/components/avatar/avatar-context.ts new file mode 100644 index 0000000..7b04673 --- /dev/null +++ b/packages/core/src/components/avatar/avatar-context.ts @@ -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(); + +/** Returns the Avatar context. Throws if used outside . */ +export function useAvatarContext(): AvatarContextValue { + const context = useContext(AvatarContext); + if (!context) { + throw new Error( + "[PettyUI] Avatar parts must be used within . Fix: Wrap Avatar.Image and Avatar.Fallback inside .", + ); + } + return context; +} + +export { AvatarContext }; diff --git a/packages/core/src/components/avatar/avatar-fallback.tsx b/packages/core/src/components/avatar/avatar-fallback.tsx new file mode 100644 index 0000000..31e11b3 --- /dev/null +++ b/packages/core/src/components/avatar/avatar-fallback.tsx @@ -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 ( + + {local.children} + + ); +} diff --git a/packages/core/src/components/avatar/avatar-image.tsx b/packages/core/src/components/avatar/avatar-image.tsx new file mode 100644 index 0000000..d15266b --- /dev/null +++ b/packages/core/src/components/avatar/avatar-image.tsx @@ -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 ( + {local.alt} + ); +} diff --git a/packages/core/src/components/avatar/avatar-root.tsx b/packages/core/src/components/avatar/avatar-root.tsx new file mode 100644 index 0000000..fbcab90 --- /dev/null +++ b/packages/core/src/components/avatar/avatar-root.tsx @@ -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 ( + + {local.children} + + ); +} diff --git a/packages/core/src/components/avatar/avatar.props.ts b/packages/core/src/components/avatar/avatar.props.ts new file mode 100644 index 0000000..251e0c3 --- /dev/null +++ b/packages/core/src/components/avatar/avatar.props.ts @@ -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 { 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, Omit, keyof z.infer> {} + +export interface AvatarFallbackProps extends JSX.HTMLAttributes { 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; diff --git a/packages/core/src/components/avatar/index.ts b/packages/core/src/components/avatar/index.ts new file mode 100644 index 0000000..afa101c --- /dev/null +++ b/packages/core/src/components/avatar/index.ts @@ -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"; diff --git a/packages/core/tests/components/avatar/avatar.test.tsx b/packages/core/tests/components/avatar/avatar.test.tsx new file mode 100644 index 0000000..6e66048 --- /dev/null +++ b/packages/core/tests/components/avatar/avatar.test.tsx @@ -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(() => MB); + expect(screen.getByTestId("fallback")).toBeTruthy(); + expect(screen.getByText("MB")).toBeTruthy(); + }); + + it("renders image element", () => { + render(() => ( + + + MB + + )); + 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"); + }); +});