{
"files": [
{
"content": "defmodule Shadix.Components.Avatar do\n @moduledoc \"\"\"\n Avatar, translated from the shadcn/ui (new-york-v4) avatar.\n\n Radix's Avatar uses an internal image-loading state machine to swap between the\n image and a fallback. This portable version reproduces that with a tiny hook,\n `ShadixAvatar` (assets/ts/avatar.ts), placed on the `<img>`: if the image loads\n it stays visible and the sibling fallback is hidden; if the image errors or has\n no `src`, the image is hidden and the fallback is revealed.\n\n Compose them as: `avatar/1` wrapper containing an `avatar_image/1` followed by\n an `avatar_fallback/1` (the fallback must come after the image so the hook can\n find it as the next sibling).\n \"\"\"\n use Phoenix.Component\n\n import Shadix.Cn\n\n @doc \"\"\"\n Wrapper for an avatar. Renders a `data-slot=\"avatar\"` span; place an\n `avatar_image/1` and then an `avatar_fallback/1` inside it.\n \"\"\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def avatar(assigns) do\n ~H\"\"\"\n <span\n data-slot=\"avatar\"\n class={cn([\"relative flex size-8 shrink-0 overflow-hidden rounded-full\", @class])}\n {@rest}\n >\n {render_slot(@inner_block)}\n </span>\n \"\"\"\n end\n\n @doc \"\"\"\n Avatar image. Carries the `ShadixAvatar` hook which hides the image and reveals\n the sibling fallback on error / missing `src`, and hides the fallback on load.\n \"\"\"\n attr(:id, :string, required: true)\n attr(:src, :string, default: nil)\n attr(:alt, :string, default: \"\")\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n\n def avatar_image(assigns) do\n ~H\"\"\"\n <img\n data-slot=\"avatar-image\"\n id={@id}\n phx-hook=\"ShadixAvatar\"\n class={cn([\"aspect-square size-full\", @class])}\n src={@src}\n alt={@alt}\n {@rest}\n />\n \"\"\"\n end\n\n @doc \"\"\"\n Avatar fallback (typically initials). Starts hidden via the `hidden` class and\n is revealed by the `ShadixAvatar` hook when the image fails to load.\n \"\"\"\n attr(:class, :string, default: nil)\n attr(:rest, :global)\n slot(:inner_block, required: true)\n\n def avatar_fallback(assigns) do\n ~H\"\"\"\n <span\n data-slot=\"avatar-fallback\"\n class={\n cn([\n \"hidden size-full flex items-center justify-center rounded-full bg-muted\",\n @class\n ])\n }\n {@rest}\n >\n {render_slot(@inner_block)}\n </span>\n \"\"\"\n end\nend\n",
"path": "avatar.ex"
}
],
"hooks": [
{
"content": "interface AvatarHook {\n el: HTMLImageElement;\n mounted(): void;\n destroyed(): void;\n}\n\n// The fallback is the avatar image's next element sibling (an\n// `[data-slot=\"avatar-fallback\"]`). We toggle the Tailwind `hidden` class so the\n// image and fallback are never visible at the same time.\nfunction fallbackOf(el: HTMLElement): HTMLElement | null {\n let sib = el.nextElementSibling;\n while (sib) {\n if (sib.getAttribute(\"data-slot\") === \"avatar-fallback\") {\n return sib as HTMLElement;\n }\n sib = sib.nextElementSibling;\n }\n return null;\n}\n\nexport const ShadixAvatar = {\n mounted(this: AvatarHook) {\n const img = this.el;\n const fallback = fallbackOf(img);\n\n const showFallback = () => {\n img.classList.add(\"hidden\");\n fallback?.classList.remove(\"hidden\");\n };\n const showImage = () => {\n img.classList.remove(\"hidden\");\n fallback?.classList.add(\"hidden\");\n };\n\n const src = img.getAttribute(\"src\");\n if (!src) {\n showFallback();\n return;\n }\n\n img.addEventListener(\"error\", showFallback);\n img.addEventListener(\"load\", showImage);\n\n // The image may have already finished loading (cache) before this hook ran.\n if (img.complete) {\n if (img.naturalWidth > 0) showImage();\n else showFallback();\n }\n\n (img as unknown as { _cleanup?: () => void })._cleanup = () => {\n img.removeEventListener(\"error\", showFallback);\n img.removeEventListener(\"load\", showImage);\n };\n },\n\n destroyed(this: AvatarHook) {\n (this.el as unknown as { _cleanup?: () => void })._cleanup?.();\n },\n};\n",
"name": "ShadixAvatar",
"path": "avatar.ts"
}
],
"name": "avatar",
"npm_deps": [],
"registry_deps": [
"cn"
]
}