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 ( ); }