defmodule Corex.Avatar do
@moduledoc ~S'''
Phoenix implementation of [Zag.js Avatar](https://zagjs.com/components/react/avatar).
## Examples
### Basic
```heex
<.avatar id="avatar" src="/me.jpg" class="avatar">
<:fallback>JD
```
### Custom fallback with `:let` (`:value` slot)
Same idea as the angle slider `value_text` slot: override the fallback body and receive the current `src` as `value`.
```heex
<.avatar id="avatar" src="/me.jpg" class="avatar">
<:value :let={value}>{if value, do: "JD", else: "?"}
```
When the `:value` slot is omitted, `<:fallback>` is used.
### `pending` vs the hooked avatar
With `pending={true}`, the component does **not** mount `phx-hook="Avatar"`, does **not** run Zag, and does **not** render an ``.
Assigns such as `src` have no visible effect until you re-render with `pending={false}`.
The `:loading` slot is only used in that pending branch (custom markup or `avatar_skeleton/1` when empty); it is **not** a replacement for the hooked avatar’s own loading UI.
### Async loading
```elixir
<.async_result :let={profile} assign={@profile}>
<:loading>
<.avatar_skeleton class="avatar" />
<:failed>Could not load.
<.avatar id="user-avatar" src={profile.avatar_url} class="avatar">
<:fallback>{profile.initials}
```
### Pending without `async_result`
```heex
<.avatar pending class="avatar">
<:loading>Loading…
```
When `pending` is true and the `:loading` slot is empty, `avatar_skeleton/1` is used.
## API
The API targets one avatar via its DOM `id` (the same `id` you pass to `avatar/1`).
- `set_src/2` and `set_src/3`
- `loaded/1`, `loaded/2`, and `loaded/3`
For `loaded`, use `respond_to: :server | :client | :both` to control whether the response is pushed to LiveView, dispatched as a DOM event, or both.
```heex
<.action phx-click={Corex.Avatar.set_src("my-avatar", "https://example.com/a.png")}>Set image
<.action phx-click={Corex.Avatar.loaded("my-avatar")}>Read loaded
```
## Events
User interaction and imperative API use different channels. See also `on_status_change` / `on_status_change_client` on `avatar/1`.
### User interaction
When `phx-hook="Avatar"` is active, Zag invokes **`on_status_change`** (server) and **`on_status_change_client`** (client `CustomEvent` type you set). Params / `event.detail` include `%{"id" => dom_id, "status" => "loaded" | "error"}` (string keys from the server; camelCase in JS `detail` as emitted by the hook).
### Imperative API (LiveView helpers and client DOM)
**From LiveView**, use `Corex.Avatar.set_src/3` and `loaded/3`. They use `push_event/3` to the hook; optional `respond_to` controls where the answer goes for `loaded/3`.
**From the client**, dispatch `CustomEvent`s on the hook root (e.g. `#my-avatar`):
| Dispatch (type) | `detail` |
|-----------------|----------|
| `corex:avatar:set-src` | `src` - image URL string |
| `corex:avatar:loaded` | optional `respond_to`: `"server"`, `"client"`, or `"both"` |
**Responses to LiveView** (`push_event` from the hook; handle in `handle_event/3`):
- `avatar_loaded_response` - `%{"id" => ..., "loaded" => boolean}`
**Responses to the DOM** (listen on the hook root element):
- `avatar-loaded` - `detail` with `id` and `loaded`
## Styling
Use data attributes to target elements:
```css
[data-scope="avatar"][data-part="root"] {}
[data-scope="avatar"][data-part="image"] {}
[data-scope="avatar"][data-part="fallback"] {}
[data-scope="avatar"][data-part="skeleton"] {}
```
If you wish to use the default Corex styling, you can use the class `avatar` on the component.
This requires to install `Mix.Tasks.Corex.Design` first and import the component css file.
```css
@import "../corex/main.css";
@import "../corex/tokens/themes/neo/light.css";
@import "../corex/components/avatar.css";
```
You can then use modifiers
```heex
<.avatar class="avatar avatar--accent avatar--lg">
<:fallback>JD
```
'''
@doc type: :component
use Phoenix.Component
alias Corex.Avatar.Anatomy.{Fallback, Image, Props, Root, Skeleton}
alias Corex.Avatar.Connect
alias Phoenix.LiveView
alias Phoenix.LiveView.JS
import Corex.Helpers, only: [respond_to_fields: 1]
attr(:id, :string, required: false, doc: "The id of the avatar")
attr(:src, :string, default: nil, doc: "Image source URL")
attr(:alt, :string, default: "", doc: "Alternative text for the image")
attr(:dir, :string, default: nil, values: [nil, "ltr", "rtl"], doc: "Direction")
attr(:pending, :boolean,
default: false,
doc:
"When true, renders only the loading UI (`:loading` slot or `avatar_skeleton/1`), without the Avatar hook."
)
attr(:on_status_change, :string,
default: nil,
doc: "Server event when image load status changes"
)
attr(:on_status_change_client, :string,
default: nil,
doc: "Client event when image load status changes"
)
attr(:rest, :global)
slot :loading,
required: false,
doc: "Custom loading content when `pending` is true. Overrides default `avatar_skeleton/1`." do
attr(:class, :string, required: false)
end
slot :fallback,
required: false,
doc: "Content for the fallback part when the image is absent or not yet shown." do
attr(:class, :string, required: false)
end
slot :value,
required: false,
doc:
"Optional replacement for `:fallback` inner content. Use `:let={value}` - `value` is the image `src` (or `nil`)." do
attr(:class, :string, required: false)
end
def avatar(assigns) do
assigns =
assigns
|> assign_new(:id, fn -> "avatar-#{System.unique_integer([:positive])}" end)
|> assign_new(:dir, fn -> nil end)
~H"""