import type { JSX, ValidComponent } from "@solidjs/web";
import { dynamic } from "@solidjs/web";
import {
createEffect,
createRenderEffect,
createSignal,
omit,
onCleanup,
Show,
untrack,
} from "solid-js";
import { mergeRootProps } from "../../internals/merge-root-props";
import { resolveClassStyle } from "../../internals/resolve-class-style";
import type { BaseUIComponentProps } from "../../internals/types";
import { useAnimationsFinished } from "../../internals/use-animations-finished";
import { useImageLoadingStatus } from "../../internals/use-image-loading-status";
import {
type TransitionStatus,
useTransitionStatus,
} from "../../internals/use-transition-status/use-transition-status";
import {
type ImageLoadingStatus,
useAvatarRootContext,
} from "../avatar-root-context";
import type { AvatarRootState } from "../root/avatar-root";
export interface AvatarImageState extends AvatarRootState {
/**
* The transition status of the component. Mirrors Base UI React's
* `AvatarImageState.transitionStatus` — drives `data-starting-style` /
* `data-ending-style` during animated mount / unmount.
*/
transitionStatus: TransitionStatus;
}
export interface AvatarImageProps
extends BaseUIComponentProps<"img", AvatarImageState> {
/** Tag/component to render. Defaults to `"img"`. */
as?: ValidComponent;
/** Callback fired when the loading status changes. */
onLoadingStatusChange?: ((status: ImageLoadingStatus) => void) | undefined;
}
/**
* The image to be displayed in the avatar. Renders an `
` element.
*
* Mount/unmount is driven by {@link useTransitionStatus}: the element mounts
* as soon as the standalone probe reports `loaded`, and stays mounted (with
* `data-ending-style`) on unload until any CSS transitions / animations
* settle — matching Base UI React's
* `useTransitionStatus` + `useOpenChangeComplete` composition.
*/
export function AvatarImage(props: AvatarImageProps): JSX.Element {
const context = useAvatarRootContext();
// Solid 2's JSX typings widen HTML attributes to `string | RemoveAttribute`
// (RFC 07 — boolean false removes the attribute). The probe only
// understands plain strings; coerce out the boolean branch here.
const asString = (v: unknown): string | undefined =>
typeof v === "string" ? v : undefined;
const status = useImageLoadingStatus({
src: () => asString(props.src),
referrerPolicy: () => asString(props.referrerpolicy),
crossOrigin: () => asString(props.crossorigin),
});
const isVisible = (): boolean => status() === "loaded";
const { mounted, transitionStatus, setMounted } =
useTransitionStatus(isVisible);
// Mirror the internal status into the root context (so ``
// can react) and notify the consumer callback. Two separate split effects
// keep the dependencies explicit.
createEffect(
() => status(),
(next) => {
if (next !== "idle") {
context.setImageLoadingStatus(next);
}
},
);
createEffect(
() => status(),
(next) => {
if (next !== "idle") {
// Resolve the latest callback at fire time, untracked — the
// apply scope is strict-read and the callback identity isn't
// a dependency of this effect.
untrack(() => props.onLoadingStatusChange)?.(next);
}
},
);
// Ensure we reset the root context status on unmount so a later remount
// starts fresh rather than reading a stale `loaded`/`error` signal.
onCleanup(() => {
context.setImageLoadingStatus("idle");
});
// Image element signal — captured via the ref forwarded to `renderElement`
// below. `ownedWrite` mirrors the pattern used by `useCollapsiblePanel`:
// the ref callback fires from an unowned scope.
const [imageElement, setImageElement] = createSignal(
null,
{ ownedWrite: true },
);
const runOnceAnimationsFinish = useAnimationsFinished(imageElement);
// Defer the unmount until CSS transitions / animations on the image settle.
// Mirrors React's `useOpenChangeComplete({ open: isVisible, onComplete: () =>
// setMounted(false) })`: when `isVisible` flips false while we're still
// mounted, wait for animations, then drop `mounted` to false so
// `useTransitionStatus` clears `transitionStatus` back to `undefined`.
createRenderEffect(
() => isVisible(),
(visible) => {
if (!visible && untrack(mounted)) {
runOnceAnimationsFinish(() => {
setMounted(false);
});
}
},
);
const imageRef = (el: HTMLImageElement | null): void => {
setImageElement(el);
};
const userStyling = resolveClassStyle(props, () => ({
imageLoadingStatus: context.imageLoadingStatus(),
transitionStatus: transitionStatus(),
}));
const userHtmlProps = omit(
props,
"class",
"style",
"attrs",
"as",
"onLoadingStatusChange",
) as Record;
const internalProps = {
ref: imageRef,
get "data-starting-style"(): "" | undefined {
return transitionStatus() === "starting" ? "" : undefined;
},
get "data-ending-style"(): "" | undefined {
return transitionStatus() === "ending" ? "" : undefined;
},
};
const styledUserProps = mergeRootProps(userStyling, userHtmlProps);
const merged = mergeRootProps(internalProps, styledUserProps);
// Only mount the real `
` once our standalone probe has fired `load`
// (matches Base UI React's `if (!mounted) return null`). Rendering the DOM
// img during `idle`/`loading` would dispatch its own load events and flash
// a broken-image icon before the probe settles. Staying mounted during the
// `ending` phase lets CSS exit animations run before the node leaves the
// tree.
const Tag = dynamic(() => props.as ?? "img");
return (
);
}