# Emitter

Every module broadcasts its open changes and its transition hooks on one typed, app-wide emitter. Read this for the events, their payloads and when the transition hooks fire.

A module broadcasts what happens to it on one emitter that every part of your
app can listen to, so a component that renders none of a dialog’s parts can
still react when that dialog has finished opening. The emitter is typed: the
event name decides the payload type, and a listener on `dialog:afterEnter`
receives a dialog payload and nothing else.

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

const emitter = useMirrorEmitter()

emitter.on('dialog:afterEnter', ({ id, part }) => {
  if (id === DialogId.EditProfile && part === 'content') {
    startTour()
  }
})
</script>
```

Reach for it when the listener and the module have no shared ancestor, or when
several modules should feed one place, such as an analytics call that logs
every popup that closes. Inside a module’s own subtree, the slot props and the
`update:*` emits on the root are closer to hand.

## Listen

`useMirrorEmitter` returns `on`, `off` and `emit`. Call it inside
`<script setup>` or another setup context: the emitter belongs to the Vue app
instance, so every component in the app meets on the same channel and two
server-rendered requests never share one. Called outside a component, it
returns an emitter nothing else is connected to.

`on(event, handler)` registers a handler for one event. Every payload carries
the instance `id`, so a listener that cares about one instance compares it.

```ts
emitter.on('select:openChange', ({ id, open }) => {
  if (id === SelectId.Country) {
    countryOpen.value = open
  }
})
```

A handler registered in setup outlives the component unless you remove it.
Pass the same function to `off` when the component unmounts.

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

import type { MirrorEmitterEvents } from '@maas/mirror/vue'

const emitter = useMirrorEmitter()

function handleOpenChange({ id, open }: MirrorEmitterEvents['popover:openChange']) {
  if (id === PopoverId.Share) {
    shareOpen.value = open
  }
}

emitter.on('popover:openChange', handleOpenChange)

onUnmounted(() => emitter.off('popover:openChange', handleOpenChange))
</script>
```

`emit` is there for your own code to fire a module’s events into the same
channel, for example from a test. Mirror’s parts emit for themselves, so you
do not call it to make a component work.

## Open changes

Eight modules broadcast `<module>:openChange` whenever they open or close,
whether a press, a key, a v-model write or a composable call caused it. The
payload carries the instance `id` and the new `open` state. Five of them add
the `reason` the state changed for, typed per module.

::docs-table
---
columns:
  - label: Event
  - label: Payload
rows:
  - items:
      - label: alert-dialog:openChange
      - label: '{ id: string, open: boolean, reason: AlertDialogChangeReason }'
        escape: true
  - items:
      - label: autocomplete:openChange
      - label: '{ id: string, open: boolean }'
        escape: true
  - items:
      - label: combobox:openChange
      - label: '{ id: string, open: boolean }'
        escape: true
  - items:
      - label: dialog:openChange
      - label: '{ id: string, open: boolean, reason: DialogChangeReason }'
        escape: true
  - items:
      - label: popover:openChange
      - label: '{ id: string, open: boolean, reason: PopoverChangeReason }'
        escape: true
  - items:
      - label: preview-card:openChange
      - label: '{ id: string, open: boolean, reason: PreviewCardChangeReason }'
        escape: true
  - items:
      - label: select:openChange
      - label: '{ id: string, open: boolean }'
        escape: true
  - items:
      - label: tooltip:openChange
      - label: '{ id: string, open: boolean, reason: TooltipChangeReason }'
        escape: true
---
::

Each component page whose payload carries a `reason` lists its values under
its own Emitter heading.

## Transition lifecycle

A part that appears and disappears under a Vue transition broadcasts the six
transition hooks as events: `beforeEnter`, `enter` and `afterEnter` on the way
in, `beforeLeave`, `leave` and `afterLeave` on the way out, each namespaced by
module, so a dialog’s popup fires `dialog:beforeEnter` through
`dialog:afterLeave`. `afterEnter` fires once the enter transition has finished,
and `afterLeave` once the element is out of the DOM, which makes them the
moments to measure the popup, move focus somewhere unusual or start something
that should wait for the motion.

Every payload carries the instance `id` and the `part` that is transitioning.
A module with two transitioning parts reports `content` or `backdrop`. A part
that is rendered several times under one root adds the value that tells them
apart: `Tabs.Content` adds its `value`, `ScrollArea.Scrollbar` its
`orientation`, and `Radio.Indicator` the `value` of the radio it sits in.

```ts
emitter.on('tabs:afterLeave', ({ id, part, value }) => {
  if (id === TabsId.Settings) {
    savedScroll[value] = viewport.value?.scrollTop ?? 0
  }
})
```

The hooks belong to the Vue transition, so they fire only while a transition
is named. Where a component ships a default name, the hooks fire out of the
box. Where the name is unset by default, the part appears and disappears at
once and broadcasts none of these events until you name one, in your CSS and
in the option or prop below. Two more cases fire nothing: a part that is
already present when it first renders, since Vue does not run an enter
transition on the first render, and a part kept in the DOM with `forceMount`,
since it never enters or leaves.

::docs-table
---
columns:
  - label: Events
  - label: Part
  - label: Where the transition is named
  - label: Default
rows:
  - items:
      - label: 'alert-dialog:*'
      - label: 'content, backdrop'
        plaintext: true
      - label: '`options.transition.content` and `.backdrop` on `AlertDialog.Root`'
        plaintext: true
      - label: none
        plaintext: true
  - items:
      - label: 'autocomplete:*'
      - label: 'content, backdrop'
        plaintext: true
      - label: '`options.transition.content` and `.backdrop` on `Autocomplete.Root`'
        plaintext: true
      - label: 'mirror-autocomplete-content, mirror-autocomplete-backdrop'
  - items:
      - label: 'avatar:*'
      - label: image
        plaintext: true
      - label: '`options.transition` on `Avatar.Root`'
        plaintext: true
      - label: none
        plaintext: true
  - items:
      - label: 'checkbox:*'
      - label: indicator
        plaintext: true
      - label: '`options.transition` on `Checkbox.Root`'
        plaintext: true
      - label: mirror-checkbox-indicator
  - items:
      - label: 'collapsible:*'
      - label: content
        plaintext: true
      - label: '`options.transition` on `Collapsible.Root`'
        plaintext: true
      - label: none
        plaintext: true
  - items:
      - label: 'combobox:*'
      - label: 'content, backdrop'
        plaintext: true
      - label: '`options.transition.content` and `.backdrop` on `Combobox.Root`'
        plaintext: true
      - label: 'mirror-combobox-content, mirror-combobox-backdrop'
  - items:
      - label: 'dialog:*'
      - label: 'content, backdrop'
        plaintext: true
      - label: '`options.transition.content` and `.backdrop` on `Dialog.Root`'
        plaintext: true
      - label: none
        plaintext: true
  - items:
      - label: 'popover:*'
      - label: 'content, backdrop'
        plaintext: true
      - label: '`options.transition.content` and `.backdrop` on `Popover.Root`'
        plaintext: true
      - label: none
        plaintext: true
  - items:
      - label: 'preview-card:*'
      - label: 'content, backdrop'
        plaintext: true
      - label: '`options.transition.content` and `.backdrop` on `PreviewCard.Root`'
        plaintext: true
      - label: none
        plaintext: true
  - items:
      - label: 'radio:*'
      - label: 'indicator, plus `value`'
        plaintext: true
      - label: 'The `transition` prop on `Radio.Indicator`'
        plaintext: true
      - label: mirror-radio-indicator
  - items:
      - label: 'scroll-area:*'
      - label: 'scrollbar, plus `orientation`'
        plaintext: true
      - label: 'The `transition` prop on `ScrollArea.Scrollbar`'
        plaintext: true
      - label: none
        plaintext: true
  - items:
      - label: 'select:*'
      - label: 'content, backdrop'
        plaintext: true
      - label: '`options.transition.content` and `.backdrop` on `Select.Root`'
        plaintext: true
      - label: 'mirror-select-content, mirror-select-backdrop'
  - items:
      - label: 'tabs:*'
      - label: 'content, plus `value`'
        plaintext: true
      - label: 'The `transition` prop on `Tabs.Content`'
        plaintext: true
      - label: none
        plaintext: true
  - items:
      - label: 'tooltip:*'
      - label: 'content, backdrop'
        plaintext: true
      - label: '`options.transition.content` and `.backdrop` on `Tooltip.Root`'
        plaintext: true
      - label: 'mirror-tooltip-content, mirror-tooltip-backdrop'
---
::

`*` stands for the six hooks, so `tabs:*` is `tabs:beforeEnter`, `tabs:enter`,
`tabs:afterEnter`, `tabs:beforeLeave`, `tabs:leave` and `tabs:afterLeave`.

An enter that is interrupted by a close does not reach `afterEnter`; the leave
hooks follow instead, so a listener waiting for the popup to have settled
should watch `afterEnter` and `afterLeave` together. `Checkbox.Indicator`,
`Radio.Indicator`, `Select`, `Combobox`, `Autocomplete` and `Tooltip` ship a
default name, so their hooks fire from the start; the others wait for a name.
[Animation](/components/animation) covers how a name maps to your CSS.

## TypeScript

The whole event map is `MirrorEmitterEvents`, and each module exports its own
slice as `<Component>Events` next to a `<Component>LifecyclePayload` for the
transition payload. `TransitionLifecycleHook` is the union of the six hook
names.

```ts
import type { MirrorEmitterEvents, TransitionLifecycleHook } from '@maas/mirror/vue'
import type { DialogEvents, DialogLifecyclePayload } from '@maas/mirror/vue/Dialog'

type DialogEnter = DialogEvents['dialog:afterEnter']

const hooks: Array<TransitionLifecycleHook> = ['beforeEnter', 'afterLeave']
```

A handler typed against `MirrorEmitterEvents['tabs:afterLeave']` accepts the
`value` the payload carries, and a handler typed against a dialog event does
not, since a dialog has one popup per instance and no value to add.

## Further reading

- [Animation](/components/animation): the transition names and what stays in
  the DOM.
- [TypeScript](/components/typescript): where the exported types live.
