# Avatar

An image with a fallback that stands in while it loads, and stays if it never does.

Avatar renders a profile picture and holds a fallback behind it until the image
has loaded. Useful wherever the picture might be missing, slow or broken, and
initials should stand in.

::component-preview{name="AvatarPreview"}
```vue
<template>
  <div class="flex flex-col items-center gap-8">
    <div class="flex items-start gap-4">
      <div
        v-for="person in people"
        :key="person.initials"
        class="flex flex-col items-center gap-2"
      >
        <Avatar.Root :class="[avatar, sizes[2]!.box, solid]">
          <Avatar.Image
            v-if="person.src"
            :src="person.src"
            :alt="person.alt"
            class="size-full"
          />
          <Avatar.Fallback :class="initials">
            {{ person.initials }}
          </Avatar.Fallback>
        </Avatar.Root>

        <span class="type-component-2xs text-surface-muted">
          {{ person.label }}
        </span>
      </div>
    </div>

    <div class="flex items-center gap-4">
      <Avatar.Root
        v-for="size in sizes"
        :key="size.name"
        :class="[avatar, size.box, translucent]"
      >
        <Avatar.Fallback :class="initials">AA</Avatar.Fallback>
      </Avatar.Root>
    </div>
  </div>
</template>

<script setup lang="ts">
import { Avatar } from '@maas/mirror/vue/Avatar'

const people = [
  { initials: 'RS', alt: 'Robin Vey', src: '/robin.jpg', label: 'image' },
  { initials: 'CJ', alt: '', src: '/missing.png', label: 'broken src' },
  { initials: 'MW', alt: '', src: '', label: 'no image' },
]

const sizes = [
  { name: 'xs', box: 'size-8 rounded-component-sm type-component-xs' },
  { name: 'sm', box: 'size-10 rounded-component-md type-component-md' },
  { name: 'md', box: 'size-12 rounded-component-lg type-component-xl' },
  { name: 'lg', box: 'size-14 rounded-component-xl type-component-3xl' },
  { name: 'xl', box: 'size-16 rounded-component-2xl type-component-5xl' },
]

const avatar = 'flex items-center justify-center overflow-hidden'

const solid = 'bg-primary-solid text-primary-on-solid'
const translucent = 'bg-primary-subtle text-primary-on-subtle'

const initials = 'font-medium tracking-[0.02em] leading-none'
</script>
```
::

The second avatar fails to load, and the third has no image at all.

## Usage guidelines

::usage-guidelines

## Anatomy

Assemble the root, an image and a fallback.

::component-anatomy
---
parts:
  - name: Avatar.Root
    required: true
    description: Renders a <span>. Owns the load status.
    children:
      - name: Avatar.Image
        description: Renders an <img>, and only once the image has loaded.
      - name: Avatar.Fallback
        description: Renders a <span> while the image has not loaded.
---
::

```vue
<script setup lang="ts">
import { Avatar } from '@maas/mirror/vue/Avatar'
</script>

<template>
  <Avatar.Root :options="{ delay: 600 }">
    <Avatar.Image src="/robin.jpg" alt="Robin Vey" />
    <Avatar.Fallback>RS</Avatar.Fallback>
  </Avatar.Root>
</template>
```

## Examples

### The load status

`Avatar.Root` owns a single piece of state, and these are the values it takes.

::docs-table
---
columns:
  - label: Status
  - label: Reached when
rows:
  - items:
      - label: idle
      - label: No `Avatar.Image`, an empty `src`, or the component is rendering on the server.
        plaintext: true
  - items:
      - label: loading
      - label: '`Avatar.Image` has started fetching a non-empty `src` in the browser.'
        plaintext: true
  - items:
      - label: loaded
      - label: The fetch resolved.
        plaintext: true
  - items:
      - label: error
      - label: The fetch failed.
        plaintext: true
---
::

`Avatar.Image` fetches through a detached `Image()` and renders the element only
once the status is `loaded`, so a broken image icon never flashes.

```vue
<template>
  <Avatar.Root v-slot="{ status }">
    <Avatar.Image src="/robin.jpg" alt="Robin Vey" />
    <Avatar.Fallback>
      <span v-if="status === 'loading'" class="spinner" />
      <span v-else>RS</span>
    </Avatar.Fallback>
  </Avatar.Root>
</template>
```

Changing `src`, `referrerPolicy` or `crossOrigin` restarts the load, and only
the most recent request may write the status.

### When the fallback shows

`Avatar.Fallback` renders while the status is anything other than `loaded`, and
with the default `options.delay` of `0` that means from the first paint.

```vue
<template>
  <Avatar.Root>
    <Avatar.Image src="/robin.jpg" alt="Robin Vey" />
    <Avatar.Fallback>RS</Avatar.Fallback>
  </Avatar.Root>
</template>
```

If the fallback is heavier than the image it stands in for, set `options.delay`
so a fast connection does not flash it.

```vue
<template>
  <Avatar.Root :options="{ delay: 600 }">
    <Avatar.Image src="/robin.jpg" alt="Robin Vey" />
    <Avatar.Fallback>RS</Avatar.Fallback>
  </Avatar.Root>
</template>
```

The timer starts when the fallback mounts and re-arms whenever a new load
starts, so a second `src` gets the same grace period as the first. It runs
regardless of what the image is doing, so a `delay` holds back the error
fallback as well as the loading one, and leaves the fallback out of the
server-rendered markup. Changing `options.delay` after mount restarts it.

`Avatar.Image` is optional, and without it the status stays `idle`, so a user
with no picture renders the fallback and nothing else.

```vue
<template>
  <Avatar.Root>
    <Avatar.Fallback>MW</Avatar.Fallback>
  </Avatar.Root>
</template>
```

### Fading the image in

The image mounts once it has loaded, on top of a fallback that is already there,
so `options.transition` names a Vue transition and the timing lives in CSS under
that name. `data-state` flips between `open` and `closed` alongside it.

```vue
<template>
  <Avatar.Root :options="{ transition: 'avatar-image' }">
    <Avatar.Image src="/robin.jpg" alt="Robin Vey" />
    <Avatar.Fallback>RS</Avatar.Fallback>
  </Avatar.Root>
</template>

<style>
.avatar-image-enter-active {
  transition: opacity 200ms ease;
}

.avatar-image-enter-from {
  opacity: 0;
}
</style>
```

Stack the image over the fallback rather than putting them side by side, or the
fallback moves out from under the fade.

The transition broadcasts its hooks through the [emitter](#emitter), so
something that renders none of the avatar’s parts can still wait for the image
to have finished entering.

### Reacting to the load

`statusChange` fires on every transition, the initial one included, so use it to
log a broken asset or swap in a different source.

```vue
<template>
  <Avatar.Root>
    <Avatar.Image :src="src" alt="Robin Vey" @status-change="onStatus" />
    <Avatar.Fallback>RS</Avatar.Fallback>
  </Avatar.Root>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import type { AvatarStatus } from '@maas/mirror/vue/Avatar'

const src = ref('https://cdn.example.com/robin.jpg')

function onStatus(status: AvatarStatus) {
  if (status === 'error') {
    src.value = '/robin.jpg'
  }
}
</script>
```

### Cross-origin images

`referrerPolicy` and `crossOrigin` are set on the detached loader as well as the
rendered `<img>`, so the request that decides the status is the one behind the
element.

```vue
<template>
  <Avatar.Root>
    <Avatar.Image
      src="https://cdn.example.com/robin.jpg"
      alt="Robin Vey"
      referrer-policy="no-referrer"
      cross-origin="anonymous"
    />
    <Avatar.Fallback>RS</Avatar.Fallback>
  </Avatar.Root>
</template>
```

## API reference

**Module.** A bundled `options` object, and `useMirrorAvatar(id)`
as the programmatic API.

### `Avatar.Root`

Renders a `<span>`.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: 'The instance ID, for [`useMirrorAvatar`](#composable).'
      - label: string
      - label: generated
        plaintext: true
  - items:
      - label: options
        description: Bundled configuration for `delay`, in milliseconds, and `transition`.
      - label: AvatarOptions
      - label: '{}'
---
::

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: status
        description: Mirrors `data-status`.
      - label: '''idle'' | ''loading'' | ''loaded'' | ''error'''
---
::

### `Avatar.Image`

Renders an `<img>` while the status is `loaded`, and nothing otherwise.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: src
        description: 'Required. An empty one leaves [the status](#the-load-status) at `idle`.'
      - label: string
      - label: none
        plaintext: true
  - items:
      - label: alt
        description: Leave it empty when something else already names the avatar.
      - label: string
      - label: ''''''
  - items:
      - label: referrerPolicy
        description: Set on the loader and the element.
      - label: ReferrerPolicy
      - label: undefined
  - items:
      - label: crossOrigin
        description: Set on the loader and the element.
      - label: ''''' | ''anonymous'' | ''use-credentials'''
      - label: undefined
---
::

#### Slot props

An `<img>` takes no children, so the slot is only reached through `as` or
`asChild`.

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: status
        description: Mirrors `data-status`.
      - label: '''idle'' | ''loading'' | ''loaded'' | ''error'''
---
::

#### Emits

::docs-table
---
columns:
  - label: Emit
  - label: Payload
rows:
  - items:
      - label: statusChange
        description: The load status changes, initial transition included.
      - label: '''idle'' | ''loading'' | ''loaded'' | ''error'''
---
::

### `Avatar.Fallback`

Renders a `<span>` while the status is anything other than `loaded`. The wait
before it renders is [`options.delay`](#when-the-fallback-shows) on the root.

#### Slot props

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: status
        description: Mirrors `data-status`.
      - label: '''idle'' | ''loading'' | ''loaded'' | ''error'''
---
::

### Composable

`useMirrorAvatar(id)` reaches an avatar from anywhere in the app.

::docs-table
---
columns:
  - label: Key
  - label: Type
rows:
  - items:
      - label: status
        description: Mirrors `data-status`.
      - label: ComputedRef<AvatarStatus>
        escape: true
  - items:
      - label: setStatus
        description: Sets the status.
      - label: '(next: AvatarStatus) => void'
        escape: true
---
::

### Emitter

Every transition hook is broadcast app-wide through `useMirrorEmitter`, so a
listener needs no shared ancestor with the avatar. Import it from
`@maas/mirror/vue` and call it inside `<script setup>`.

The six transition hooks are broadcast as `avatar:beforeEnter`, `avatar:enter`,
`avatar:afterEnter`, `avatar:beforeLeave`, `avatar:leave` and
`avatar:afterLeave`. Each payload carries the instance `id` and the `part` that
is transitioning, which is always `image`.

```vue
<script setup lang="ts">
import { useMirrorEmitter } from '@maas/mirror/vue'

const emitter = useMirrorEmitter()

emitter.on('avatar:afterEnter', ({ id }) => {
  if (id === AvatarId.Profile) {
    revealName()
  }
})
</script>
```

The hooks belong to the Vue transition, so they fire only while
`options.transition` names one. It is unset by default, and until you name one
the avatar broadcasts no lifecycle events at all. The image is not in the DOM
until it has loaded, so nothing fires on the first render.

The image enters once its load has resolved and leaves the moment `src`
changes, so a source swap broadcasts the leave hooks first and the enter hooks
once the new image has loaded. A source that fails to load never enters, so a
swap to a broken one broadcasts only the leave hooks.

::docs-table
---
columns:
  - label: Event
  - label: Payload
rows:
  - items:
      - label: avatar:beforeEnter
        description: The image is about to enter.
      - label: "{ id: string, part: 'image' }"
        escape: true
  - items:
      - label: avatar:enter
        description: The image has started entering.
      - label: "{ id: string, part: 'image' }"
        escape: true
  - items:
      - label: avatar:afterEnter
        description: The image has finished entering.
      - label: "{ id: string, part: 'image' }"
        escape: true
  - items:
      - label: avatar:beforeLeave
        description: The image is about to leave.
      - label: "{ id: string, part: 'image' }"
        escape: true
  - items:
      - label: avatar:leave
        description: The image has started leaving.
      - label: "{ id: string, part: 'image' }"
        escape: true
  - items:
      - label: avatar:afterLeave
        description: The image has finished leaving and is out of the DOM.
      - label: "{ id: string, part: 'image' }"
        escape: true
---
::

`AvatarLifecyclePayload` types the lifecycle payload, and the whole map is
`AvatarEvents`. [Emitter](/components/emitter) covers the channel itself.

### Data attributes

::data-attributes
::

### CSS variables

::css-variables
::

### Errors

::docs-table
---
columns:
  - label: Code
rows:
  - items:
      - label: missing_avatar_context
        description: '`Avatar.Image` or `Avatar.Fallback` rendered outside `Avatar.Root`.'
---
::

## Accessibility

There are no roles, no keyboard behavior and no focus to manage. The image has
its own `alt`, and the fallback is plain text.
