Avatar component
Image + fallback pattern with idle/loading/loaded/error status tracking via context. 4 tests passing.
This commit is contained in:
parent
5c503ee9ef
commit
80f7af401a
21
packages/core/src/components/avatar/avatar-context.ts
Normal file
21
packages/core/src/components/avatar/avatar-context.ts
Normal 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 };
|
||||||
15
packages/core/src/components/avatar/avatar-fallback.tsx
Normal file
15
packages/core/src/components/avatar/avatar-fallback.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
packages/core/src/components/avatar/avatar-image.tsx
Normal file
28
packages/core/src/components/avatar/avatar-image.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
packages/core/src/components/avatar/avatar-root.tsx
Normal file
15
packages/core/src/components/avatar/avatar-root.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
packages/core/src/components/avatar/avatar.props.ts
Normal file
21
packages/core/src/components/avatar/avatar.props.ts
Normal 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;
|
||||||
8
packages/core/src/components/avatar/index.ts
Normal file
8
packages/core/src/components/avatar/index.ts
Normal 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";
|
||||||
34
packages/core/tests/components/avatar/avatar.test.tsx
Normal file
34
packages/core/tests/components/avatar/avatar.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user